From f712a8cbcbbdcd4091e6fe4e9a00da8c2a0fae99 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 21 Oct 2026 15:01:48 +0200 Subject: [PATCH 1/4] feat(storage): read native iOS and Android app storage - Read App Group containers on iOS simulators under AppGroup//, so shared UserDefaults and SwiftData stores in them are found; reset empties them too. - Recognize SwiftData's default.store and any SQLite file with -wal or -shm companions, and show Core Data and SwiftData dates in the table grid. - Read every table's row count and columns in single queries; parallel sqlite3 processes failed on the write-ahead log lock. - Mark EncryptedSharedPreferences as encrypted and decode Proto DataStore files as numbered fields without a schema. - Add mobile_app_storage_file and file previews in the panel, decoding JSON, binary and XML property lists, NSKeyedArchiver archives, text, and hex. --- README.md | 2 +- docs/telemetry.md | 8 +- skills/mobile-dev/SKILL.md | 2 +- src/server/app-storage/container.ts | 53 ++++++-- src/server/app-storage/formats.ts | 56 +++++++- src/server/app-storage/preview.ts | 84 ++++++++++++ src/server/app-storage/reader.ts | 40 ++++-- src/server/app-storage/tools.ts | 13 +- src/server/telemetry.ts | 2 +- src/shared/app-storage.ts | 13 +- src/shared/version.ts | 2 +- src/ui/components/storage-browse.tsx | 109 ++++++++++----- src/ui/components/storage-entries.tsx | 2 +- src/ui/storage-labels.ts | 4 +- src/ui/storage-panel.ts | 28 +++- tests/app-storage.test.ts | 1 - tests/fixtures/app-storage/native/profile.pb | 3 + .../app-storage/native/secret_prefs.xml | 7 + .../app-storage/native/session.archive | Bin 0 -> 408 bytes tests/fixtures/app-storage/native/state.plist | Bin 0 -> 133 bytes tests/native-storage.test.ts | 125 ++++++++++++++++++ 21 files changed, 472 insertions(+), 82 deletions(-) create mode 100644 src/server/app-storage/preview.ts create mode 100644 tests/fixtures/app-storage/native/profile.pb create mode 100644 tests/fixtures/app-storage/native/secret_prefs.xml create mode 100644 tests/fixtures/app-storage/native/session.archive create mode 100644 tests/fixtures/app-storage/native/state.plist create mode 100644 tests/native-storage.test.ts diff --git a/README.md b/README.md index f825fad9..cc8637d6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Track app CPU and memory, expand individual thread charts, and view device-wide ![](/img/screenshot_performance.png) ### 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. Open SQLite tables as a paged grid, 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. +See what an app has saved without adding code to it, whether it is built with React Native or natively: UserDefaults and App Group containers, Core Data and SwiftData, SharedPreferences, DataStore, MMKV, AsyncStorage, SQLite databases, and the largest files in its data directory. Open SQLite tables as a paged grid, open any file to see JSON, property lists, and archives decoded, 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 d432500d..d2120783 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -213,19 +213,19 @@ another; each caller keeps its own timing name (`ui.app_tools.icons`, `ui.storag 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_app_storage_table`, `mobile_clear_app_storage`) uses the `storage` +The Storage tool (`mobile_app_storage`, `mobile_app_storage_table`, `mobile_app_storage_file`, `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`, `table`) with the device +`app_storage.operation.samples` per operation (`read`, `clear`, `reset`, `table`, `file`) 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`, `.icons`, `.read`, `.table`, +through `server.error.outcome`. The panel records `ui.storage.list_apps`, `.icons`, `.read`, `.table`, `.file`, `.clear`, `.reset`, and `.launch` round trips, `ui.storage.process` for validating 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; table names and cell values from `mobile_app_storage_table` -stay local too. Since 0.1.161 the Changes tab reads storage again every +and file contents from `mobile_app_storage_file` stay local too. Since 0.1.161 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. diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md index 7b1e002b..2e42b335 100644 --- a/skills/mobile-dev/SKILL.md +++ b/skills/mobile-dev/SKILL.md @@ -39,7 +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_app_storage_table` pages through a SQLite table's rows. `mobile_clear_app_storage` stops the app and deletes one store, or all of its data when no store is given, for testing a fresh state such as a logged-out or first-launch flow. Deleted data cannot be recovered, so confirm with the user before clearing data they may need. +- `mobile_app_storage` reads what an app saved, without changes to the app, for React Native and native apps alike: UserDefaults (including App Group containers shared with widgets and extensions), Core Data and SwiftData stores, SharedPreferences, DataStore (Preferences, and Proto as numbered fields because the file has no schema), MMKV, AsyncStorage, 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 and EncryptedSharedPreferences stay locked. Android needs a debuggable build. `mobile_app_storage_table` pages through a SQLite table's rows; Core Data and SwiftData dates are seconds since 2001-01-01. `mobile_app_storage_file` reads one listed file, decoding JSON, property lists, and NSKeyedArchiver archives, so Codable or archived files can be checked too. `mobile_clear_app_storage` stops the app and deletes one store, or all of its data when no store is given, for testing a fresh state such as a logged-out or first-launch flow. Deleted data cannot be recovered, so confirm with the user before clearing data they may need. 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 index 3283414e..3791372e 100644 --- a/src/server/app-storage/container.ts +++ b/src/server/app-storage/container.ts @@ -29,16 +29,24 @@ function safePath(path: string) { return path; } +// Paths under this prefix live in one of the app's App Group containers, shared with its widgets and extensions. +export const appGroupPrefix = "AppGroup/"; +const preferencesPlist = /(?:^|\/)Library\/Preferences\/[^/]+\.plist$/; +const containerMetadata = ".com.apple.mobile_container_manager.metadata.plist"; + export class SimulatorContainer implements AppContainer { readonly platform = "ios"; private readonly root: string; private readonly deviceId: string; private readonly commands: Commands; + // App Group ID to its container directory. + private readonly groups: ReadonlyMap; - constructor(root: string, deviceId: string, commands: Commands) { + constructor(root: string, deviceId: string, commands: Commands, groups: ReadonlyMap = new Map()) { this.root = root; this.deviceId = deviceId; this.commands = commands; + this.groups = groups; } static async open(deviceId: string, bundleId: string, commands: Commands) { @@ -48,31 +56,47 @@ export class SimulatorContainer implements AppContainer { 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); + // Apps without the App Groups entitlement list none. + const groups = await commands.run("xcrun", ["simctl", "get_app_container", deviceId, bundleId, "groups"], { timeout: 15000 }).catch(() => ""); + const entries = groups.split("\n").map(line => line.split("\t")).filter(([id, path]) => id && path && !id.includes("/")).map(([id, path]) => [id.trim(), path.trim()] as const); + return new SimulatorContainer(output.trim(), deviceId, commands, new Map(entries)); + } + + // Container-relative path to its directory on disk. + private resolve(path: string) { + safePath(path); + if (path.startsWith(appGroupPrefix)) { + const [id, ...rest] = path.slice(appGroupPrefix.length).split("/"); + const group = this.groups.get(id); + if (group === undefined || rest.length === 0) throw new ExpectedOperationError("invalid_input", "The storage path is outside the app's data."); + return join(group, ...rest); + } + return join(this.root, path); } async list() { const files: ContainerFile[] = []; - const walk = async (directory: string) => { + const walk = async (root: string, prefix: string, 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); + if (entry.isDirectory()) await walk(root, prefix, 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 }); + if (info) files.push({ path: prefix + relative(root, path).split(sep).join("/"), bytes: info.size }); } } }; - await walk(this.root); + await walk(this.root, "", this.root); + for (const [id, root] of this.groups) await walk(root, `${appGroupPrefix}${id}/`, root); return files; } - async read(path: string) { return readFile(join(this.root, safePath(path))); } + async read(path: string) { return readFile(this.resolve(path)); } async remove(paths: string[]) { - await Promise.all(paths.map(path => rm(join(this.root, safePath(path)), { recursive: true, force: true }))); + await Promise.all(paths.map(path => rm(this.resolve(path), { recursive: true, force: true }))); } private defaults(args: string[]) { @@ -81,26 +105,29 @@ export class SimulatorContainer implements AppContainer { // `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$/, ""); + const domain = this.resolve(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(() => ""); + await this.defaults(["delete", this.resolve(path).replace(/\.plist$/, "")]).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); + for (const file of await this.list()) if (preferencesPlist.test(file.path)) 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 }); + // App Group containers are emptied too: SwiftData and shared defaults often live there. + for (const root of this.groups.values()) { + const entries = await readdir(root).catch(() => []); + await Promise.all(entries.filter(entry => entry !== containerMetadata).map(entry => rm(join(root, entry), { recursive: true, force: true }))); + } } } diff --git a/src/server/app-storage/formats.ts b/src/server/app-storage/formats.ts index 8373e3a9..5b8173b1 100644 --- a/src/server/app-storage/formats.ts +++ b/src/server/app-storage/formats.ts @@ -36,6 +36,13 @@ function tokens(xml: string): Token[] { } export function parsePlistXml(xml: string): Record { + const top = parsePlistValue(xml); + 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; +} + +// Any property list root, for previews of plist files. +export function parsePlistValue(xml: string): PlistValue { const list = tokens(xml); let index = 0; const next = () => list[index++]; @@ -87,12 +94,10 @@ export function parsePlistXml(xml: string): Record { 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; + return value(); } -function plainPlist(value: PlistValue): unknown { +export 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(); @@ -195,6 +200,35 @@ export function parseDataStorePreferences(buffer: Buffer): ParsedEntry[] { }); } +// ---- Proto DataStore and other schema-less protobuf: field numbers become keys, and value types are guessed. + +const printable = (bytes: Buffer) => { + const text = bytes.toString("utf8"); + return Buffer.from(text, "utf8").equals(bytes) && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffd]/.test(text) ? text : undefined; +}; +const message = (bytes: Buffer) => { try { const parsed = fields(bytes); return parsed.length > 0 ? parsed : undefined; } catch { return undefined; } }; + +// Short text often also parses as protobuf, so text wins over a nested message. +export function protobufEntries(buffer: Buffer, prefix = ""): ParsedEntry[] { + const counts = new Map(); + for (const field of fields(buffer)) counts.set(field.number, (counts.get(field.number) ?? 0) + 1); + const seen = new Map(); + return fields(buffer).flatMap(field => { + const index = seen.get(field.number) ?? 0; + seen.set(field.number, index + 1); + const key = `${prefix}${field.number}${(counts.get(field.number) ?? 0) > 1 ? `[${index}]` : ""}`; + const entry = (type: EntryType, value: string, bytes: number): ParsedEntry[] => [{ key, type, value, bytes, guessed: true }]; + if (field.varint !== undefined) return entry("integer", BigInt.asIntN(64, field.varint).toString(), 8); + const bytes = field.bytes!; + if (field.wire === 5) return entry("number", String(bytes.readFloatLE(0)), 4); + if (field.wire === 1) return entry("number", String(bytes.readDoubleLE(0)), 8); + const text = printable(bytes); + if (text !== undefined) return [{ ...textEntry(key, text, bytes.length), guessed: true }]; + if (message(bytes)) return protobufEntries(bytes, `${key}.`); + return entry("data", bytes.toString("hex"), bytes.length); + }); +} + // ---- Classifying files in the data directory. export type StoreCandidate = { kind: StoreKind; name: string; path: string; files: string[] }; @@ -217,7 +251,8 @@ export function classifyFiles(paths: readonly string[], platform: "ios" | "andro 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")) { + // App Group containers keep their own Library/Preferences, under the AppGroup// prefix. + if (platform === "ios" && /(?:^|\/)Library\/Preferences$/.test(directory) && name.endsWith(".plist")) { stores.push({ kind: "user-defaults", name: name.slice(0, -6), path, files: [path] }); continue; } @@ -233,13 +268,20 @@ export function classifyFiles(paths: readonly string[], platform: "ios" | "andro stores.push({ kind: "datastore", name: name.slice(0, -".preferences_pb".length), path, files: [path] }); continue; } + if (name.endsWith(".pb") && basename(directory) === "datastore") { + stores.push({ kind: "datastore", name: name.slice(0, -3), 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)) { + // SwiftData's default.store and custom-named Core Data stores are SQLite too; a -wal or -shm file next to any + // file marks it as a database in WAL mode. + const sqliteName = platform === "android" ? directory === "databases" && !sqliteSuffix.test(name) && !name.endsWith(".bak") : /\.(?:db|sqlite3?|sql|store)$/i.test(name); + const walCompanion = !sqliteSuffix.test(name) && (all.has(`${path}-wal`) || all.has(`${path}-shm`)); + if ((sqliteName || walCompanion || /\.(?: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))] }); } } diff --git a/src/server/app-storage/preview.ts b/src/server/app-storage/preview.ts new file mode 100644 index 00000000..5c966951 --- /dev/null +++ b/src/server/app-storage/preview.ts @@ -0,0 +1,84 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import type { StorageFilePreview } from "../../shared/app-storage.ts"; +import { withTemporaryDirectory } from "../app-control/commands.ts"; +import type { CommandRunner } from "../app-control/commands.ts"; +import type { AppContainer } from "./container.ts"; +import { parsePlistValue, plainPlist } from "./formats.ts"; + +type PreviewOptions = { maxBytes: number; maxLength: number; hexBytes: number; run: CommandRunner }; + +const utf8 = new TextDecoder("utf-8", { fatal: true }); +function text(bytes: Buffer) { + try { + const value = utf8.decode(bytes); + return /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value) ? undefined : value; + } catch { return undefined; } +} + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const uid = (value: unknown) => isRecord(value) && Object.keys(value).length === 1 && typeof value["CF$UID"] === "number" ? value["CF$UID"] : undefined; + +// NSKeyedArchiver flattens objects into $objects and links them by UID. Following the links from $top gives back the +// archived dictionaries, arrays, strings, and dates. +export function unarchive(archive: Record): unknown { + const objects = Array.isArray(archive.$objects) ? archive.$objects : []; + const resolve = (value: unknown, path: ReadonlySet): unknown => { + const index = uid(value); + if (index !== undefined) { + if (path.has(index)) return ""; + const target = objects[index]; + return target === "$null" ? null : resolve(target, new Set(path).add(index)); + } + if (Array.isArray(value)) return value.map(item => resolve(item, path)); + if (!isRecord(value)) return value; + if (Array.isArray(value["NS.keys"]) && Array.isArray(value["NS.objects"])) { + const values = value["NS.objects"]; + return Object.fromEntries((value["NS.keys"] as unknown[]).map((key, index) => [String(resolve(key, path)), resolve(values[index], path)])); + } + if (Array.isArray(value["NS.objects"])) return resolve(value["NS.objects"], path); + if (typeof value["NS.string"] === "string") return value["NS.string"]; + // NSDate stores seconds since 2001-01-01. + if (typeof value["NS.time"] === "number") return new Date((value["NS.time"] + 978307200) * 1000).toISOString(); + if (value["NS.data"] !== undefined) return value["NS.data"]; + return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$class").map(([key, child]) => [key, resolve(child, path)])); + }; + const top = isRecord(archive.$top) ? archive.$top : {}; + return resolve("root" in top ? top.root : top, new Set()); +} + +async function plistXml(bytes: Buffer, run: CommandRunner) { + if (bytes.subarray(0, 6).toString("latin1") !== "bplist") return bytes.toString("utf8"); + if (process.platform !== "darwin") return undefined; + return withTemporaryDirectory(async directory => { + const file = join(directory, "preview.plist"); + await writeFile(file, bytes); + return run("plutil", ["-convert", "xml1", "-o", "-", file], { timeout: 10000, maxBuffer: 64 * 1024 * 1024 }); + }); +} + +// A file from the app's data directory, decoded by its contents rather than its name. +export async function previewFile(container: AppContainer, path: string, { maxBytes, maxLength, hexBytes, run }: PreviewOptions): Promise { + const file = (await container.list()).find(item => item.path === path); + if (file === undefined) throw new ExpectedOperationError("invalid_input", "That file no longer exists. Refresh and try again."); + const shown = (format: StorageFilePreview["format"], content: string): StorageFilePreview => ({ + path, bytes: file.bytes, format, content: content.length > maxLength ? content.slice(0, maxLength) : content, truncated: content.length > maxLength, + }); + if (file.bytes > maxBytes) return { path, bytes: file.bytes, format: "binary", content: "", truncated: true }; + const bytes = await container.read(path); + const head = bytes.subarray(0, 64).toString("latin1"); + if (head.startsWith("bplist") || (head.startsWith(" undefined); + const value = xml === undefined ? undefined : (() => { try { return plainPlist(parsePlistValue(xml)); } catch { return undefined; } })(); + if (isRecord(value) && value.$archiver === "NSKeyedArchiver") return shown("archive", JSON.stringify(unarchive(value), null, 2)); + if (value !== undefined) return shown("plist", JSON.stringify(value, null, 2)); + } + const content = text(bytes); + if (content !== undefined) { + try { return shown("json", JSON.stringify(JSON.parse(content), null, 2)); } + catch { return shown("text", content); } + } + const hex = bytes.subarray(0, hexBytes).toString("hex"); + return { ...shown("binary", hex), truncated: bytes.length > hexBytes }; +} diff --git a/src/server/app-storage/reader.ts b/src/server/app-storage/reader.ts index 052e4569..5033ebb8 100644 --- a/src/server/app-storage/reader.ts +++ b/src/server/app-storage/reader.ts @@ -7,7 +7,7 @@ import type { StorageEntry, StorageFile, StorageSnapshot, StorageStore, StorageT import { withTemporaryDirectory } from "../app-control/commands.ts"; import type { CommandRunner } from "../app-control/commands.ts"; import type { AppContainer, ContainerFile } from "./container.ts"; -import { basename, classifyFiles, nullEntry, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries, storeId, textEntry } from "./formats.ts"; +import { basename, classifyFiles, nullEntry, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries, protobufEntries, storeId, textEntry } from "./formats.ts"; import type { ParsedEntry, StoreCandidate } from "./formats.ts"; import { decodeMmkv, guessMmkvValue } from "./mmkv.ts"; @@ -17,6 +17,7 @@ type StoreContent = { status: StorageStore["status"]; note?: string; entries: Pa const sqlite3 = process.platform === "darwin" ? "/usr/bin/sqlite3" : "sqlite3"; const quoteIdentifier = (name: string) => `"${name.replaceAll("\"", "\"\"")}"`; +const sqlString = (text: string) => `'${text.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 }); @@ -49,13 +50,16 @@ async function readSqlite(container: AppContainer, candidate: StoreCandidate, ru const entries = rows.map(row => row.value === null || row.value === undefined ? nullEntry(String(row.key)) : textEntry(String(row.key), String(row.value))); return { status: "ok", kind: "async-storage", entries }; } - 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 }; + if (tables.length === 0) return { status: "ok", entries: [] }; + // Two queries cover every table. Running one sqlite3 per table in parallel let the first process recover the + // write-ahead log while the others failed on its lock. + const counts = new Map((await sqlite(run, file, tables.map(table => `SELECT ${sqlString(table)} AS name, count(*) AS total FROM ${quoteIdentifier(table)}`).join(" UNION ALL "))) + .map(row => [String(row.name), Number(row.total)])); + const columns = new Map(); + for (const row of await sqlite(run, file, "SELECT m.name AS tbl, p.name AS col FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid")) { + columns.set(String(row.tbl), [...columns.get(String(row.tbl)) ?? [], String(row.col)]); + } + return { status: "ok", entries: tables.map(table => ({ key: table, type: "table", value: `${counts.get(table) ?? "?"} rows · ${(columns.get(table) ?? []).join(", ")}`, bytes: 0 })) }; }); } @@ -91,10 +95,19 @@ async function readStore(container: AppContainer, candidate: StoreCandidate, run 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 "shared-preferences": { + const entries = parseSharedPreferences((await container.read(candidate.path)).toString("utf8")); + // EncryptedSharedPreferences stores its keysets beside encrypted keys and values, which would read as noise. + if (entries.some(entry => entry.key.startsWith("__androidx_security_crypto_encrypted_prefs_"))) { + return { status: "encrypted", note: "EncryptedSharedPreferences: keys and values are encrypted with a key in the Android Keystore.", entries: [] }; + } + return { status: "ok", entries }; + } + case "datastore": { + const bytes = await container.read(candidate.path); + if (candidate.path.endsWith(".preferences_pb")) return { status: "ok", entries: parseDataStorePreferences(bytes) }; + return { status: "ok", note: "Proto DataStore has no schema in the file, so keys are field numbers and types are guessed.", entries: protobufEntries(bytes) }; + } case "async-storage": return readAsyncManifest(container, candidate); case "sqlite": @@ -169,7 +182,8 @@ export async function readTable(container: AppContainer, id: string, table: stri return withDatabase(container, candidate, async file => { if (!(await listTables(run, file)).includes(table)) throw new ExpectedOperationError("invalid_input", `The table ${table} no longer exists. Refresh and try again.`); const name = quoteIdentifier(table); - const [info, [count]] = await Promise.all([sqlite(run, file, `PRAGMA table_info(${name})`), sqlite(run, file, `SELECT count(*) AS total FROM ${name}`)]); + const info = await sqlite(run, file, `PRAGMA table_info(${name})`); + const [count] = await sqlite(run, file, `SELECT count(*) AS total FROM ${name}`); const columns = info.map(column => ({ name: String(column.name), type: String(column.type ?? "") })); // Blobs cannot travel as JSON, so they become a hex preview no longer than the value limit. const select = columns.map((column, index) => { diff --git a/src/server/app-storage/tools.ts b/src/server/app-storage/tools.ts index 00a7d21d..46721b70 100644 --- a/src/server/app-storage/tools.ts +++ b/src/server/app-storage/tools.ts @@ -14,9 +14,10 @@ import type { DriverFactory } from "../app-control/tools.ts"; import { AndroidContainer, SimulatorContainer } from "./container.ts"; import type { AppContainer } from "./container.ts"; import { clearStore, readStorage, readTable } from "./reader.ts"; +import { previewFile } from "./preview.ts"; type Target = { platform: "ios" | "android"; deviceId: string }; -type Operation = "read" | "clear" | "reset" | "table"; +type Operation = "read" | "clear" | "reset" | "table" | "file"; export type ContainerFactory = (target: Target, bundleId: string) => Promise; export function createContainers(commands: Commands = systemCommands): ContainerFactory { @@ -79,6 +80,16 @@ export function registerAppStorageTools(server: McpServer, commands: Commands = offset: input.offset ?? 0, limit: input.limit ?? storageLimits.tableRows, valueLength: storageLimits.tableValueLength, run: commands.run, }))); + registerAppTool(server, "mobile_app_storage_file", { + title: "Read an app data file", + description: "Read one file listed by mobile_app_storage, decoded by its contents: JSON, property lists (including binary and NSKeyedArchiver archives) as JSON, text, or hex for other binary data.", + inputSchema: { ...appTargetShape, bundleId, path: z.string().min(1).max(4096).describe("A file path from mobile_app_storage's files list.") }, + annotations: readOnly, + _meta: { ui: { visibility: ["app", "model"] } }, + }, input => run("file", input, container => previewFile(container, input.path, { + maxBytes: storageLimits.previewBytes, maxLength: storageLimits.previewLength, hexBytes: storageLimits.hexBytes, run: commands.run, + }))); + registerAppTool(server, "mobile_clear_app_storage", { title: "Clear app storage", description: "Stop the app, then delete one store listed by mobile_app_storage, or all of the app's saved data when store is omitted. Deleted data cannot be recovered. The app stays installed and is not relaunched; launch it again to test a fresh state.", diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts index 2b794fcf..a717d799 100644 --- a/src/server/telemetry.ts +++ b/src/server/telemetry.ts @@ -27,7 +27,7 @@ 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" | "table", platform: "ios" | "android", outcome: "success" | "failed", duration: number) { +export function recordAppStorageOperation(operation: "read" | "clear" | "reset" | "table" | "file", 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 }); diff --git a/src/shared/app-storage.ts b/src/shared/app-storage.ts index fac1503c..3b3daf23 100644 --- a/src/shared/app-storage.ts +++ b/src/shared/app-storage.ts @@ -56,7 +56,7 @@ 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. Tables are read a page of rows at a time. -export const storageLimits = { entries: 200, maxEntries: 2000, valueLength: 300, maxValueLength: 16000, files: 400, tableRows: 100, maxTableRows: 500, tableValueLength: 2000 }; +export const storageLimits = { entries: 200, maxEntries: 2000, valueLength: 300, maxValueLength: 16000, files: 400, tableRows: 100, maxTableRows: 500, tableValueLength: 2000, previewBytes: 8 * 1024 * 1024, previewLength: 200000, hexBytes: 4096 }; // One page of rows from a SQLite table. Cells are text, numbers, or null; blobs arrive as x'…' hex. const tableCell = z.union([z.string(), z.number(), z.null()]); @@ -68,3 +68,14 @@ export const storageTableSchema = z.object({ offset: z.number().int().nonnegative(), }); export type StorageTable = z.infer; + +// One file's contents. json, plist, and archive (NSKeyedArchiver) arrive as JSON text; binary as hex of its first bytes. +export const filePreviewFormats = ["json", "plist", "archive", "text", "binary"] as const; +export const storageFileSchema = z.object({ + path: z.string(), + bytes: z.number().int().nonnegative(), + format: z.enum(filePreviewFormats), + content: z.string(), + truncated: z.boolean(), +}); +export type StorageFilePreview = z.infer; diff --git a/src/shared/version.ts b/src/shared/version.ts index cf6eeaec..bb9747e8 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.161"; +export const PLUGIN_VERSION = "0.1.162"; diff --git a/src/ui/components/storage-browse.tsx b/src/ui/components/storage-browse.tsx index d7735698..408a5845 100644 --- a/src/ui/components/storage-browse.tsx +++ b/src/ui/components/storage-browse.tsx @@ -1,11 +1,12 @@ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, LockIcon, PuzzleIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; import { useMemo, useState, useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; import { storageLimits, storeKindLabels, storeKinds } from "../../shared/app-storage.ts"; -import type { StorageEntry, StorageSnapshot, StorageStore } from "../../shared/app-storage.ts"; +import type { StorageEntry, StorageFilePreview, StorageSnapshot, StorageStore } from "../../shared/app-storage.ts"; import { isWorking } from "../storage-panel.ts"; -import type { StoragePanel, StorageTableView } from "../storage-panel.ts"; +import type { StorageFileView, StoragePanel, StorageTableView } from "../storage-panel.ts"; import { formatBytes, isSdkStore, storeJson, storeTitle } from "../storage-labels.ts"; -import { EntryRow, entryMatches } from "./storage-entries"; +import { EntryRow, JsonTree, entryMatches } from "./storage-entries"; import { ConfirmButton, FilesIcon, KindIcon, SidebarRow } from "./storage-parts"; import { Alert, AlertDescription } from "./ui/alert"; import { Button } from "./ui/button"; @@ -16,6 +17,45 @@ const FILES = "files"; type Expansion = { expanded?: string; toggle: (store: StorageStore, key: string) => void; copy: (text: string) => void }; const entryId = (store: StorageStore, key: string) => `${store.id}\u0000${key}`; +// The header above a store or the file list. Opening a table or file swaps the icon for a back button of the +// same size and adds the item's name to the title. +function DetailHeader({ icon, title, child, subtitle, path, back, loading, children }: { icon: ReactNode; title: string; child?: string; subtitle: string; path: string; back?: { label: string; onBack: () => void }; loading?: boolean; children?: ReactNode }) { + return
+ {back ? : icon} +
+
{title}{child && <>/{child}}
+
{subtitle}{path && <> · {path}}
+
+
+ {loading && } + {children} +
+
; +} + +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; +const formatLabels: Record = { json: "JSON", plist: "Property list", archive: "NSKeyedArchiver archive", text: "Text", binary: "Binary" }; + +// A file's contents: structured formats as a JSON tree, text as is, and other bytes as hex. +function FilePreview({ file }: { file: StorageFileView }) { + const data = file.data; + let body: ReactNode = null; + if (data && data.format === "binary") { + body = data.content ?
{data.content.replace(/(..)(?!$)/g, "$1 ")}
: null; + } else if (data) { + const parsed = data.format === "text" ? undefined : (() => { try { return JSON.parse(data.content) as unknown; } catch { return undefined; } })(); + body = parsed !== undefined ? :
{data.content}
; + } + const note = !data ? "" : data.format === "binary" && !data.content ? `Too large to preview (${formatBytes(data.bytes)}).` + : data.format === "binary" && data.truncated ? `Showing the first ${formatBytes(data.content.length / 2)} as hex.` + : data.truncated ? "Cut short; the file is longer." : ""; + return
+ {file.error && {file.error}} + {note &&

{note}

} +
{body}
+
; +} + // 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])); @@ -30,26 +70,33 @@ function containerBreakdown(snapshot: StorageSnapshot) { 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 FilesView({ snapshot }: { snapshot: StorageSnapshot }) { +function FilesView({ panel, snapshot, file }: { panel: StoragePanel; snapshot: StorageSnapshot; file?: StorageFileView }) { + if (file) { + const listed = snapshot.files.find(item => item.path === file.path); + const subtitle = file.data ? `${formatLabels[file.data.format]} · ${formatBytes(file.data.bytes)}` : listed ? formatBytes(listed.bytes) : "Loading…"; + return
+ panel.closeFile() }} loading={file.loading} /> + +
; + } const breakdown = containerBreakdown(snapshot); return
-
- - Files{snapshot.fileCount} files · {formatBytes(snapshot.totalBytes)} in the app's data directory -
+ } title="Files" subtitle={`${plural(snapshot.fileCount, "file")} · ${formatBytes(snapshot.totalBytes)} in the app's data directory`} path="" />
{breakdown.map(([label, bytes, tone]) => )}
{breakdown.filter(([, bytes]) => bytes > 0).map(([label, bytes, tone]) => {label}{formatBytes(bytes)})}
- {snapshot.files.map(file => { - const owner = file.store ? snapshot.stores.find(item => item.id === file.store) : undefined; - return
- {file.path} + {snapshot.files.map(item => { + const owner = item.store ? snapshot.stores.find(store => store.id === item.store) : undefined; + return
; + {formatBytes(item.bytes)} + + ; })} {snapshot.files.length < snapshot.fileCount &&

Showing the {snapshot.files.length} largest files.

}
; @@ -70,6 +117,10 @@ function SearchResults({ groups, query, title, expansion }: { groups: { store: S ; } +// Core Data and SwiftData store dates in TIMESTAMP columns as seconds since 2001-01-01. +const appleDate = (type: string, cell: string | number) => typeof cell === "number" && /^timestamp$/i.test(type) + ? new Date((cell + 978307200) * 1000).toISOString() : undefined; + // One page of a SQLite table as a grid. Long cells are cut short and show in full on hover. function TableGrid({ table }: { table: StorageTableView }) { const data = table.data; @@ -83,10 +134,13 @@ function TableGrid({ table }: { table: StorageTableView }) { )} {data.rows.map((row, index) => - {row.map((cell, column) => - {cell === null ? NULL : String(cell)} - )} + {row.map((cell, column) => { + const date = cell === null ? undefined : appleDate(data.columns[column].type, cell); + const tone = date ? "" : typeof cell === "number" ? "text-blue-600 tabular-nums dark:text-blue-400" : cell === null || /^x'/.test(cell) ? "text-muted-foreground" : ""; + return + {cell === null ? NULL : date ?? String(cell)} + ; + })} )} } {data && data.rows.length === 0 &&

This table has no rows.

} @@ -111,25 +165,16 @@ function TablePager({ panel, store, table }: { panel: StoragePanel; store: Stora // A store's header and entries. A database lists its tables; opening one shows it in place, with a back button // where the store icon was. function StoreDetail({ panel, store, title, table, busy, expansion }: { panel: StoragePanel; store: StorageStore; title: string; table?: StorageTableView; busy: boolean; expansion: Expansion }) { - const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; const subtitle = table ? table.data ? `${plural(table.data.columns.length, "column")} · ${plural(table.data.total, "row")}` : "Loading…" : `${storeKindLabels[store.kind]} · ${plural(store.entryCount, store.kind === "sqlite" ? "table" : "key")} · ${formatBytes(store.bytes)}`; return
-
- {table ? - : } -
-
{title}{table && <>/{table.name}}
-
{subtitle} · {store.path}
-
-
- {table?.loading && } + } title={title} child={table?.name} subtitle={subtitle} path={store.path} + back={table && { label: "Back to tables", onBack: () => panel.closeTable() }} loading={table?.loading}> {table && } {store.status === "ok" && store.kind !== "sqlite" && } void panel.clear(store.id)} variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive">Clear -
-
+ {store.note &&

{store.status === "encrypted" ? : store.status === "unreadable" ? : null}{store.note}

} {table ? :
{store.entries.map(entry => store.kind === "sqlite" && entry.type === "table" @@ -156,7 +201,7 @@ export function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePan const store = snapshot.stores.find(item => item.id === selected) ?? (selected === FILES ? undefined : own[0] ?? sdks[0]); const expansion: Expansion = { expanded, copy, toggle: (item, key) => setExpanded(expanded === entryId(item, key) ? undefined : entryId(item, key)) }; const visible = (item: StorageStore) => !query || (matches.get(item.id)?.length ?? 0) > 0; - const select = (id: string) => { setSelected(id); setExpanded(undefined); panel.closeTable(); }; + const select = (id: string) => { setSelected(id); setExpanded(undefined); panel.closeTable(); panel.closeFile(); }; const storeRow = (item: StorageStore) => } title={title(item)} subtitle={storeKindLabels[item.kind]} tooltip={item.path} selected={!query && store?.id === item.id} onSelect={() => select(item.id)} @@ -189,6 +234,6 @@ export function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePan const main = query ? ({ store: item, entries: matches.get(item.id) ?? [] })).filter(group => group.entries.length > 0)} query={query} title={title} expansion={expansion} /> : store ? - : ; + : ; return
{sidebar}
{picker}{main}
; } diff --git a/src/ui/components/storage-entries.tsx b/src/ui/components/storage-entries.tsx index 0dc14d8e..61a89dd5 100644 --- a/src/ui/components/storage-entries.tsx +++ b/src/ui/components/storage-entries.tsx @@ -38,7 +38,7 @@ function ValuePreview({ entry, query }: { entry: StorageEntry; query: string }) return ; } -function JsonTree({ value }: { value: unknown }): ReactNode { +export 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)}; diff --git a/src/ui/storage-labels.ts b/src/ui/storage-labels.ts index 547fbec0..31740bfd 100644 --- a/src/ui/storage-labels.ts +++ b/src/ui/storage-labels.ts @@ -4,12 +4,14 @@ import type { StorageEntry, StorageStore } from "../shared/app-storage.ts"; 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\.|expo\.modules\.)/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, MMKV's default instance, and AsyncStorage's Android database get plain names. +// The app's own preferences domain, MMKV's default instance, AsyncStorage's Android database, and SwiftData's default +// store 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"; if (store.kind === "async-storage" && store.name === "RKStorage") return "default"; + if (store.kind === "sqlite" && store.name === "default.store") return "SwiftData"; return store.name; } diff --git a/src/ui/storage-panel.ts b/src/ui/storage-panel.ts index 5a8025de..7cf30997 100644 --- a/src/ui/storage-panel.ts +++ b/src/ui/storage-panel.ts @@ -1,8 +1,8 @@ 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, storageTableSchema } from "../shared/app-storage.ts"; -import type { StorageEntry, StorageSnapshot, StorageStore, StorageTable } from "../shared/app-storage.ts"; +import { storageClearSchema, storageFileSchema, storageLimits, storageSnapshotSchema, storageTableSchema } from "../shared/app-storage.ts"; +import type { StorageEntry, StorageFilePreview, StorageSnapshot, StorageStore, StorageTable } 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"; @@ -25,6 +25,7 @@ export type StorageChangeEvent = { after?: StorageEntry; }; export type StorageTableView = { storeId: string; name: string; data?: StorageTable; loading: boolean; error: string }; +export type StorageFileView = { path: string; data?: StorageFilePreview; loading: boolean; error: string }; export type StorageState = { open: boolean; available: boolean; @@ -43,6 +44,8 @@ export type StorageState = { showSdkChanges: boolean; // The database table open in Browse, page by page. table?: StorageTableView; + // The file open in Files. + file?: StorageFileView; watchingSince?: number; busy: StorageAction | null; error: string; @@ -138,7 +141,7 @@ export class StoragePanel { } this.revision++; this.appsFor = ""; - this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: [], table: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); + this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: [], table: undefined, file: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); if (this.state.open) void this.loadApps(); } @@ -180,6 +183,23 @@ export class StoragePanel { closeTable() { if (this.state.table) this.update({ table: undefined }); } + async openFile(path: string) { + const bundleId = this.state.bundleId; + if (!this.ready() || !bundleId) return; + const current = this.current(); + const file: StorageFileView = { path, loading: true, error: "" }; + this.update({ file }); + const stillOpen = () => current() && this.state.file?.path === path; + try { + const data = storageFileSchema.parse(await measureUiOperation("ui.storage.file", "ui.storage.failure", () => this.call("mobile_app_storage_file", { bundleId, path }))); + if (stillOpen()) this.update({ file: { ...file, data, loading: false } }); + } catch (error) { + if (stillOpen()) this.update({ file: { ...file, loading: false, error: errorMessage(error) } }); + } + } + + closeFile() { if (this.state.file) this.update({ file: undefined }); } + clearChanges() { this.update({ changes: [], watchingSince: this.state.snapshot?.readAt }); } // Only the visible Changes tab re-reads storage; Browse refreshes on request. @@ -232,7 +252,7 @@ export class StoragePanel { if (bundleId !== this.state.bundleId) { this.revision++; this.selections.set(`${this.state.platform}:${this.state.deviceId}`, bundleId); - this.update({ bundleId, snapshot: undefined, changes: [], table: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); + this.update({ bundleId, snapshot: undefined, changes: [], table: undefined, file: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); } if (bundleId) void this.refresh(); } diff --git a/tests/app-storage.test.ts b/tests/app-storage.test.ts index 810d7dca..bc4e3d69 100644 --- a/tests/app-storage.test.ts +++ b/tests/app-storage.test.ts @@ -145,7 +145,6 @@ class MemoryContainer implements AppContainer { 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); } } diff --git a/tests/fixtures/app-storage/native/profile.pb b/tests/fixtures/app-storage/native/profile.pb new file mode 100644 index 00000000..6fe480b1 --- /dev/null +++ b/tests/fixtures/app-storage/native/profile.pb @@ -0,0 +1,3 @@ + + Ada Lovelace$"beta"admin* +Berlin10115 \ No newline at end of file diff --git a/tests/fixtures/app-storage/native/secret_prefs.xml b/tests/fixtures/app-storage/native/secret_prefs.xml new file mode 100644 index 00000000..d572367a --- /dev/null +++ b/tests/fixtures/app-storage/native/secret_prefs.xml @@ -0,0 +1,7 @@ + + + AUupwcvoFM8Beh/LwwwFPzF25sT/MP6XnpeUqw5l12AmWGNaAnL8QkTl/XXOeAFHOQjdzTs= + 12a90127e2b32053abe0d7a31b4bc8391af2f9d5550cc06cef9865f8a73596a4e42f49d6685a216cfdc82c9ff0f833b40b3d9f36fc9ec850d87ef9801598dc145ef93c223fcae29dee37723acdb0bce1c3a235247826f7ebd307a33336fcbc018cdb473d167a21ea3cfe29927deaa75f520be101b47b278ec408b31a6a23846c105eadcd62a72b297c59ea95f0d33dfc113c644eced1be0357635fb9e2be68a084608a709eec7fdcebe95d261a4408f8dda5c102123c0a30747970652e676f6f676c65617069732e636f6d2f676f6f676c652e63727970746f2e74696e6b2e4165735369764b6579100118f8dda5c1022001 + AUupwctmrnZf3fGS2U/4WRIWGPbkQzYliycAYTSjVOPaI6vJ4nSz7WcIOd3B + 128801aaf3bf7c480ffdbe5439f2869bac282ff54368eab0f5e7d24b5babbac8c1d1ac7d53f1a56a82858b85f5eb311d42f11a7a2089be267f16dbc09017262a09d0200e29066b7ae3c10b1c9a9e63238c11aa41d7abc6485e06e721a5d0e5342a78bc34dda18543217a548257d901760a2fd45825af5f95d04baa02334762c38fef3d63576cb6934ba0ed1a4408cb83a7dd04123c0a30747970652e676f6f676c65617069732e636f6d2f676f6f676c652e63727970746f2e74696e6b2e41657347636d4b6579100118cb83a7dd042001 + diff --git a/tests/fixtures/app-storage/native/session.archive b/tests/fixtures/app-storage/native/session.archive new file mode 100644 index 0000000000000000000000000000000000000000..1b8baa12e912983931bf8ce4ab7658e522aa3c76 GIT binary patch literal 408 zcmY+9J4gdT6h-gkW8yD~@&DIk{ey^&b-+fHh^(3@LD_63xMt1D%&4T1X%rDbMC@g; zva=CH#L_xZ>}{21^CVq)TjxZECZm9HkL>{7-JE5D*>Oy4jZ zwWtVdJE>UdO{xnX206T34+Z1uEG^JXtYRwqs~4GN2}!PN>ss5|dwTl@hnAH5j%hyE z2ZEu-mPA|~&(Q*3by&$-R&-P2+-?X9w8jed=)gY>KN`>h1tXZm6lO4o1*lj<203u-;{ZoE#tBYw shI3rt3OBgL9q#dfM?9g3SG?gJpZLNz0r8SBX~y5UNSPR`umTeK0VI8QE&u=k literal 0 HcmV?d00001 diff --git a/tests/fixtures/app-storage/native/state.plist b/tests/fixtures/app-storage/native/state.plist new file mode 100644 index 0000000000000000000000000000000000000000..4b99d9cd1302190b11177aeba5dfe61f15593ddb GIT binary patch literal 133 zcmYc)$jK}&F)+By$i&RT$`+B6Selodky;#;lUQ65oLrQenirFnnpjd=lmGdVx6I5sb}+&?cVKd~qUD9gdg!7rg~U}Wv!6%ok* P28@glnt>Zi!>A|#FJvM- literal 0 HcmV?d00001 diff --git a/tests/native-storage.test.ts b/tests/native-storage.test.ts new file mode 100644 index 00000000..91a3833a --- /dev/null +++ b/tests/native-storage.test.ts @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { classifyFiles, protobufEntries } from "../src/server/app-storage/formats.ts"; +import { readStorage } from "../src/server/app-storage/reader.ts"; +import { previewFile, unarchive } from "../src/server/app-storage/preview.ts"; +import { SimulatorContainer } from "../src/server/app-storage/container.ts"; +import type { AppContainer } from "../src/server/app-storage/container.ts"; +import { systemCommands } from "../src/server/app-control/commands.ts"; +import { expectedOutcome } from "../src/shared/error-reporting.ts"; + +const fixture = (name: string) => readFile(new URL(`./fixtures/app-storage/native/${name}`, import.meta.url)); +const udid = "B5C969F6-58A4-4C31-AB12-FB9E56D681DE"; +const preview = { maxBytes: 1024 * 1024, maxLength: 10000, hexBytes: 16, run: systemCommands.run }; + +class MemoryContainer implements AppContainer { + readonly platform: "ios" | "android"; + readonly files: Map; + constructor(platform: "ios" | "android", files: Record) { + this.platform = platform; + 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 remove() {} + async reset() {} +} + +test("native stores are found by their files: SwiftData, Core Data with any name, App Group defaults, and Proto DataStore", () => { + const kinds = (platform: "ios" | "android", paths: string[]) => classifyFiles(paths, platform).map(store => `${store.kind}:${store.name}:${store.files.length}`); + assert.deepEqual(kinds("ios", [ + "AppGroup/group.dev.example/Library/Application Support/default.store", "AppGroup/group.dev.example/Library/Application Support/default.store-wal", + "Library/Application Support/Model", "Library/Application Support/Model-wal", + "AppGroup/group.dev.example/Library/Preferences/group.dev.example.plist", + "Documents/profile.json", "Library/Caches/thumbnails-wal", + ]), ["sqlite:default.store:2", "sqlite:Model:2", "user-defaults:group.dev.example:1"]); + assert.deepEqual(kinds("android", ["files/datastore/profile.pb", "files/datastore/settings.preferences_pb", "files/other.pb"]), + ["datastore:profile:1", "datastore:settings:1"]); +}); + +test("Proto DataStore without a schema reads as numbered fields, with repeats indexed and nested messages flattened", async () => { + assert.deepEqual(protobufEntries(await fixture("profile.pb")).map(entry => [entry.key, entry.type, entry.value]), [ + ["1", "string", "Ada Lovelace"], ["2", "integer", "36"], ["3", "integer", "1"], + ["4[0]", "string", "beta"], ["4[1]", "string", "admin"], ["5.1", "string", "Berlin"], ["5.2", "string", "10115"], + ]); +}); + +test("EncryptedSharedPreferences reads as encrypted instead of ciphertext keys and values", async () => { + const container = new MemoryContainer("android", { + "shared_prefs/secret_prefs.xml": await fixture("secret_prefs.xml"), + "files/datastore/profile.pb": await fixture("profile.pb"), + }); + const snapshot = await readStorage(container, { bundleId: "dev.example", entries: 50, valueLength: 100, run: systemCommands.run }); + const secret = snapshot.stores.find(store => store.name === "secret_prefs"); + assert.equal(secret?.status, "encrypted"); + assert.deepEqual(secret?.entries, []); + assert.match(snapshot.stores.find(store => store.name === "profile")?.note ?? "", /no schema/); +}); + +test("simulator containers include App Group containers under a prefix and keep paths inside them", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-groups-")); + try { + const data = join(root, "data"); + const group = join(root, "group"); + await mkdir(join(data, "Documents"), { recursive: true }); + await mkdir(join(group, "Library/Preferences"), { recursive: true }); + await writeFile(join(data, "Documents/notes.txt"), "app"); + await writeFile(join(group, "widget.json"), "{}"); + await writeFile(join(group, ".com.apple.mobile_container_manager.metadata.plist"), "keep"); + const container = new SimulatorContainer(data, udid, systemCommands, new Map([["group.dev.example", group]])); + assert.deepEqual((await container.list()).map(file => file.path).sort(), [ + "AppGroup/group.dev.example/.com.apple.mobile_container_manager.metadata.plist", "AppGroup/group.dev.example/widget.json", "Documents/notes.txt", + ]); + assert.equal((await container.read("AppGroup/group.dev.example/widget.json")).toString(), "{}"); + await assert.rejects(container.read("AppGroup/group.other/widget.json"), error => expectedOutcome(error) === "invalid_input"); + await assert.rejects(container.read("AppGroup/group.dev.example/../data/Documents/notes.txt"), error => expectedOutcome(error) === "invalid_input"); + await container.remove(["AppGroup/group.dev.example/widget.json"]); + assert.deepEqual(await readdir(group), [".com.apple.mobile_container_manager.metadata.plist", "Library"]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("file previews decode JSON, text, and binary, and reject files that are not listed", async () => { + const container = new MemoryContainer("android", { + "files/profile.json": "{\"name\":\"Ada\",\"tags\":[\"beta\"]}", + "files/notes.txt": "first line\nsecond line\n", + "cache/blob.bin": Buffer.from([0, 1, 2, 3, 255, 254]), + }); + const json = await previewFile(container, "files/profile.json", preview); + assert.deepEqual([json.format, JSON.parse(json.content)], ["json", { name: "Ada", tags: ["beta"] }]); + assert.deepEqual(await previewFile(container, "files/notes.txt", preview), { path: "files/notes.txt", bytes: 23, format: "text", content: "first line\nsecond line\n", truncated: false }); + assert.deepEqual(await previewFile(container, "cache/blob.bin", preview), { path: "cache/blob.bin", bytes: 6, format: "binary", content: "00010203fffe", truncated: false }); + await assert.rejects(previewFile(container, "files/missing.txt", preview), error => expectedOutcome(error) === "invalid_input"); +}); + +test("binary property lists and NSKeyedArchiver archives preview as JSON", { skip: process.platform !== "darwin" && "needs plutil" }, async () => { + const container = new MemoryContainer("ios", { "state.plist": await fixture("state.plist"), "session.archive": await fixture("session.archive") }); + const plist = await previewFile(container, "state.plist", preview); + assert.deepEqual([plist.format, JSON.parse(plist.content)], ["plist", { featureFlags: { darkIcons: false, newOnboarding: true }, lastScreen: "settings", launches: 7 }]); + const archive = await previewFile(container, "session.archive", preview); + assert.deepEqual([archive.format, JSON.parse(archive.content)], ["archive", { recentSearches: ["swiftui", "core data"], scrollOffset: 240 }]); +}); + +test("unarchiving follows UIDs, resolves NSDate, and stops at cycles", () => { + const archive = { $archiver: "NSKeyedArchiver", $top: { root: { "CF$UID": 1 } }, $objects: ["$null", + { "NS.keys": [{ "CF$UID": 2 }, { "CF$UID": 3 }], "NS.objects": [{ "CF$UID": 4 }, { "CF$UID": 1 }], $class: { "CF$UID": 5 } }, + "opened", "self", { "NS.time": 0, $class: { "CF$UID": 6 } }, { $classname: "NSDictionary" }, { $classname: "NSDate" }] }; + assert.deepEqual(unarchive(archive), { opened: "2001-01-01T00:00:00.000Z", self: "" }); +}); + +test("SQLite files with any name list every table's rows and columns in one read", { skip: process.platform !== "darwin" && "needs sqlite3" }, async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-swiftdata-")); + try { + const support = join(root, "Library/Application Support"); + await mkdir(support, { recursive: true }); + execFileSync("/usr/bin/sqlite3", [join(support, "default.store"), "CREATE TABLE ZITEM (Z_PK INTEGER PRIMARY KEY, ZTITLE TEXT); INSERT INTO ZITEM VALUES (1, 'a'), (2, 'b'); CREATE TABLE Z_METADATA (Z_VERSION INTEGER); CREATE TABLE \"odd 'name\" (x);"]); + const container = new SimulatorContainer(root, udid, systemCommands); + const snapshot = await readStorage(container, { bundleId: "dev.example", entries: 50, valueLength: 100, run: systemCommands.run }); + assert.deepEqual(snapshot.stores.find(store => store.name === "default.store")?.entries.map(entry => entry.value), [ + "2 rows · Z_PK, ZTITLE", "0 rows · Z_VERSION", "0 rows · x", + ]); + } finally { await rm(root, { recursive: true, force: true }); } +}); From 11957a7b2ed181dbc8787854b1c1bf2baa6fa5f3 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 21 Oct 2026 15:01:48 +0200 Subject: [PATCH 2/4] test(storage): add native iOS and Android storage test apps A SwiftUI app writes UserDefaults, App Group defaults, Core Data, SwiftData, and Codable, plist, and archived files. A Kotlin app writes SharedPreferences, EncryptedSharedPreferences, SQLite, DataStore Preferences and Proto, MMKV, and files. --- examples/native-storage-android/.gitignore | 7 + examples/native-storage-android/README.md | 48 ++++ .../app/build.gradle.kts | 48 ++++ .../app/src/main/AndroidManifest.xml | 19 ++ .../nativestorage/android/MainActivity.kt | 255 ++++++++++++++++++ .../nativestorage/android/NativeStorageApp.kt | 12 + .../nativestorage/android/NotesDatabase.kt | 50 ++++ .../android/ProfileSerializer.kt | 92 +++++++ .../native-storage-android/build.gradle.kts | 4 + .../native-storage-android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/native-storage-android/gradlew | 248 +++++++++++++++++ examples/native-storage-android/gradlew.bat | 98 +++++++ examples/native-storage-android/run.sh | 26 ++ .../settings.gradle.kts | 18 ++ examples/native-storage-ios/.gitignore | 2 + .../NativeStorageTest.entitlements | 10 + .../project.pbxproj | 206 ++++++++++++++ .../xcschemes/NativeStorageTest.xcscheme | 22 ++ .../NativeStorageTest/ContentView.swift | 39 +++ .../NativeStorageTestApp.swift | 8 + .../NativeStorageTest/Storage.swift | 139 ++++++++++ examples/native-storage-ios/README.md | 35 +++ examples/native-storage-ios/run.sh | 24 ++ 25 files changed, 1420 insertions(+) create mode 100644 examples/native-storage-android/.gitignore create mode 100644 examples/native-storage-android/README.md create mode 100644 examples/native-storage-android/app/build.gradle.kts create mode 100644 examples/native-storage-android/app/src/main/AndroidManifest.xml create mode 100644 examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/MainActivity.kt create mode 100644 examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NativeStorageApp.kt create mode 100644 examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NotesDatabase.kt create mode 100644 examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/ProfileSerializer.kt create mode 100644 examples/native-storage-android/build.gradle.kts create mode 100644 examples/native-storage-android/gradle.properties create mode 100644 examples/native-storage-android/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/native-storage-android/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/native-storage-android/gradlew create mode 100644 examples/native-storage-android/gradlew.bat create mode 100755 examples/native-storage-android/run.sh create mode 100644 examples/native-storage-android/settings.gradle.kts create mode 100644 examples/native-storage-ios/.gitignore create mode 100644 examples/native-storage-ios/NativeStorageTest.entitlements create mode 100644 examples/native-storage-ios/NativeStorageTest.xcodeproj/project.pbxproj create mode 100644 examples/native-storage-ios/NativeStorageTest.xcodeproj/xcshareddata/xcschemes/NativeStorageTest.xcscheme create mode 100644 examples/native-storage-ios/NativeStorageTest/ContentView.swift create mode 100644 examples/native-storage-ios/NativeStorageTest/NativeStorageTestApp.swift create mode 100644 examples/native-storage-ios/NativeStorageTest/Storage.swift create mode 100644 examples/native-storage-ios/README.md create mode 100755 examples/native-storage-ios/run.sh diff --git a/examples/native-storage-android/.gitignore b/examples/native-storage-android/.gitignore new file mode 100644 index 00000000..458fab6f --- /dev/null +++ b/examples/native-storage-android/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +build/ +app/build/ +local.properties +*.iml +.idea/ diff --git a/examples/native-storage-android/README.md b/examples/native-storage-android/README.md new file mode 100644 index 00000000..2b06be5a --- /dev/null +++ b/examples/native-storage-android/README.md @@ -0,0 +1,48 @@ +# Native Storage Android test app + +A small Kotlin app (plain `Activity`, no Compose, no Room or code generation) that +writes realistic data through the standard Android storage APIs. It is a fixture for +the plugin's on-device storage reader. Requires JDK 17, the Android SDK (API 36), and +an arm64 emulator or device running Android 8.0+. + +## Run + +Boot an emulator, then from the repository root: + +```sh +./examples/native-storage-android/run.sh +``` + +The script builds the release variant with the Gradle wrapper, installs it with +`adb -s install -r`, and launches it. The release build type is +**debuggable** and signed with the debug keystore so the plugin can read app data +with `adb shell run-as`. It packages only `arm64-v8a` native libraries (MMKV). + +- Package: `dev.mobile.nativestorage.android` +- Label: Native Storage +- Versions: AGP 8.13.2, Kotlin 2.2.21, Gradle 9.4.1 + +## What each button writes + +| Button | Location | Data | +| --- | --- | --- | +| Sign in | `shared_prefs/dev.mobile.nativestorage.android_preferences.xml` | `user.name`, `user.id`, `isLoggedIn`, `lastLogin`, `fontScale`, `interests` (string set), `session` (JSON string) | +| Sign out | same file | Removes the keys above | +| Save secret | `shared_prefs/secret_prefs.xml` | `EncryptedSharedPreferences` (AES256_GCM master key): `api_token`, `pin` | +| Add note | `databases/notes.db` | One row per tap in `notes(id, title, body, pinned, created_at, thumbnail BLOB)` | +| Toggle theme | `files/datastore/settings.preferences_pb` | Preferences DataStore `settings`: `theme`, `notifications`, `launchCount` | +| Save profile | `files/datastore/profile.pb` | Proto DataStore with a hand-written protobuf wire-format serializer (name=1, age=2, premium=3, tags=4, address=5 with city=1, zip=2) | +| Write MMKV | `files/mmkv/mmkv.default`, `files/mmkv/cache` | Default instance (`user.name`, `launchCount`, `onboarded`) and the `cache` instance | +| Write files | `files/notes.txt`, `files/profile.json`, `cache/image-cache.bin` | Text, pretty JSON, 256 KB of random bytes | +| Clear everything | all of the above | Clears or deletes the data from inside the app | + +A summary under the buttons shows what the app reads back after each tap. + +## Inspect + +```sh +adb -s shell run-as dev.mobile.nativestorage.android ls -R +``` + +Build output stays in the ignored `.gradle/`, `build/`, and `app/build/` directories. +This app is a local test fixture, excluded from plugin packaging. diff --git a/examples/native-storage-android/app/build.gradle.kts b/examples/native-storage-android/app/build.gradle.kts new file mode 100644 index 00000000..9e07f3f6 --- /dev/null +++ b/examples/native-storage-android/app/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "dev.mobile.nativestorage.android" + compileSdk = 36 + + defaultConfig { + applicationId = "dev.mobile.nativestorage.android" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + // MMKV ships native libs for every ABI; keep the fixture small. + ndk { abiFilters += listOf("arm64-v8a") } + } + + buildTypes { + release { + // The plugin reads app data through `adb shell run-as`, which needs a debuggable build. + isDebuggable = true + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("androidx.datastore:datastore-preferences:1.1.7") + implementation("androidx.datastore:datastore-core:1.1.7") + implementation("com.google.protobuf:protobuf-javalite:4.28.3") + implementation("com.tencent:mmkv:1.3.14") +} diff --git a/examples/native-storage-android/app/src/main/AndroidManifest.xml b/examples/native-storage-android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..923258cc --- /dev/null +++ b/examples/native-storage-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/MainActivity.kt b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/MainActivity.kt new file mode 100644 index 00000000..9073f283 --- /dev/null +++ b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/MainActivity.kt @@ -0,0 +1,255 @@ +package dev.mobile.nativestorage.android + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.tencent.mmkv.MMKV +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.Random + +// Stored at files/datastore/settings.preferences_pb +private val Context.settingsStore: DataStore by preferencesDataStore(name = "settings") + +// Stored at files/datastore/profile.pb. One DataStore instance per file. +private val Context.profileStore: DataStore by lazyProfileStore() + +private fun lazyProfileStore(): kotlin.properties.ReadOnlyProperty> { + var instance: DataStore? = null + return kotlin.properties.ReadOnlyProperty { context, _ -> + synchronized(ProfileSerializer) { + instance ?: DataStoreFactory.create( + serializer = ProfileSerializer, + produceFile = { File(context.applicationContext.filesDir, "datastore/profile.pb") }, + ).also { instance = it } + } + } +} + +class MainActivity : Activity() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private lateinit var summary: TextView + + private val themeKey = stringPreferencesKey("theme") + private val notificationsKey = booleanPreferencesKey("notifications") + private val launchCountKey = intPreferencesKey("launchCount") + + private val userKeys = listOf( + "user.name", "user.id", "isLoggedIn", "lastLogin", "fontScale", "interests", "session", + ) + + private val defaultPrefs: SharedPreferences + get() = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + + private val notes by lazy { NotesDatabase(applicationContext) } + + private val securePrefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + applicationContext, + "secret_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + summary = TextView(this).apply { + textSize = 14f + setPadding(0, dp(16), 0, 0) + } + val column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(16), dp(16), dp(16)) + } + column.addView( + TextView(this).apply { + text = "Native Storage" + textSize = 22f + setPadding(0, 0, 0, dp(8)) + }, + ) + action(column, "Sign in") { signIn() } + action(column, "Sign out") { signOut() } + action(column, "Save secret") { saveSecret() } + action(column, "Add note") { notes.addNote() } + action(column, "Toggle theme") { toggleTheme() } + action(column, "Save profile") { saveProfile() } + action(column, "Write MMKV") { writeMmkv() } + action(column, "Write files") { writeFiles() } + action(column, "Clear everything") { clearEverything() } + column.addView(summary) + setContentView( + ScrollView(this).apply { + // Target SDK 35+ draws edge-to-edge; keep buttons out of the system bars. + fitsSystemWindows = true + addView( + column, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + }, + ) + refreshSummary() + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() + + private fun action(parent: LinearLayout, label: String, block: suspend () -> Unit) { + parent.addView( + Button(this).apply { + text = label + isAllCaps = false + setOnClickListener { + scope.launch { + try { + withContext(Dispatchers.IO) { block() } + } catch (e: Exception) { + summary.text = "$label failed: ${e::class.java.simpleName}: ${e.message}" + return@launch + } + refreshSummary() + } + } + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + + private fun signIn() { + defaultPrefs.edit() + .putString("user.name", "Ada Lovelace") + .putInt("user.id", 4211) + .putBoolean("isLoggedIn", true) + .putLong("lastLogin", System.currentTimeMillis()) + .putFloat("fontScale", 1.1f) + .putStringSet("interests", setOf("kotlin", "android")) + .putString("session", JSONObject().put("token", "tok_live_1234").put("plan", "pro").toString()) + .apply() + } + + private fun signOut() { + defaultPrefs.edit().apply { userKeys.forEach { remove(it) } }.apply() + } + + private fun saveSecret() { + securePrefs.edit() + .putString("api_token", "sk_test_4242") + .putString("pin", "1234") + .apply() + } + + private suspend fun toggleTheme() { + settingsStore.edit { prefs -> + prefs[themeKey] = if (prefs[themeKey] == "dark") "light" else "dark" + prefs[notificationsKey] = true + prefs[launchCountKey] = (prefs[launchCountKey] ?: 0) + 1 + } + } + + private suspend fun saveProfile() { + profileStore.updateData { + Profile( + name = "Ada Lovelace", + age = 36, + premium = true, + tags = listOf("beta", "admin"), + address = Address(city = "Berlin", zip = "10115"), + ) + } + } + + private fun writeMmkv() { + val defaults = MMKV.defaultMMKV() + defaults.encode("user.name", "Ada Lovelace") + defaults.encode("launchCount", defaults.decodeInt("launchCount", 0) + 1) + defaults.encode("onboarded", true) + val cache = MMKV.mmkvWithID("cache") + cache.encode("last_sync", System.currentTimeMillis()) + cache.encode("feed_etag", "W/\"a1b2c3\"") + cache.encode("hit_ratio", 0.82) + } + + private fun writeFiles() { + File(filesDir, "notes.txt").writeText("Remember to review the storage fixture.\nSecond line of notes.\n") + val profile = JSONObject() + .put("name", "Ada Lovelace") + .put("age", 36) + .put("premium", true) + .put("tags", JSONArray(listOf("beta", "admin"))) + .put("address", JSONObject().put("city", "Berlin").put("zip", "10115")) + File(filesDir, "profile.json").writeText(profile.toString(2) + "\n") + File(cacheDir, "image-cache.bin").writeBytes(ByteArray(256 * 1024).also { Random(42).nextBytes(it) }) + } + + private suspend fun clearEverything() { + defaultPrefs.edit().clear().apply() + securePrefs.edit().clear().apply() + notes.clear() + settingsStore.edit { it.clear() } + profileStore.updateData { ProfileSerializer.defaultValue } + MMKV.defaultMMKV().clearAll() + MMKV.mmkvWithID("cache").clearAll() + File(filesDir, "notes.txt").delete() + File(filesDir, "profile.json").delete() + File(cacheDir, "image-cache.bin").delete() + } + + private fun refreshSummary() { + scope.launch { + val text = withContext(Dispatchers.IO) { + val prefs = defaultPrefs + val settings = settingsStore.data.first() + val profile = profileStore.data.first() + buildString { + appendLine("prefs user.name: ${prefs.getString("user.name", null) ?: "-"}") + appendLine("secret api_token set: ${securePrefs.contains("api_token")}") + appendLine("notes: ${notes.count()}") + appendLine("theme: ${settings[themeKey] ?: "-"}, launchCount: ${settings[launchCountKey] ?: 0}") + appendLine("profile: ${profile.name.ifEmpty { "-" }} (${profile.address.city.ifEmpty { "-" }})") + appendLine("mmkv user.name: ${MMKV.defaultMMKV().decodeString("user.name") ?: "-"}") + append("files: ${File(filesDir, "notes.txt").exists()}, cache: ${File(cacheDir, "image-cache.bin").exists()}") + } + } + summary.text = text + } + } +} diff --git a/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NativeStorageApp.kt b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NativeStorageApp.kt new file mode 100644 index 00000000..0941fef0 --- /dev/null +++ b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NativeStorageApp.kt @@ -0,0 +1,12 @@ +package dev.mobile.nativestorage.android + +import android.app.Application +import com.tencent.mmkv.MMKV + +class NativeStorageApp : Application() { + override fun onCreate() { + super.onCreate() + // Stores MMKV files under filesDir/mmkv. + MMKV.initialize(this) + } +} diff --git a/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NotesDatabase.kt b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NotesDatabase.kt new file mode 100644 index 00000000..41cb9227 --- /dev/null +++ b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/NotesDatabase.kt @@ -0,0 +1,50 @@ +package dev.mobile.nativestorage.android + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class NotesDatabase(context: Context) : SQLiteOpenHelper(context, "notes.db", null, 1) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE notes ( + id INTEGER PRIMARY KEY, + title TEXT, + body TEXT, + pinned INTEGER, + created_at INTEGER, + thumbnail BLOB + ) + """.trimIndent(), + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("DROP TABLE IF EXISTS notes") + onCreate(db) + } + + fun addNote(): Int { + val number = count() + 1 + val values = ContentValues().apply { + put("title", "Note $number") + put("body", "Body of note $number written by the Native Storage fixture.") + put("pinned", if (number % 2 == 0) 1 else 0) + put("created_at", System.currentTimeMillis()) + put("thumbnail", ByteArray(32) { ((it * 7 + number) and 0xFF).toByte() }) + } + writableDatabase.insertOrThrow("notes", null, values) + return number + } + + fun count(): Int = + readableDatabase.rawQuery("SELECT COUNT(*) FROM notes", null).use { + if (it.moveToFirst()) it.getInt(0) else 0 + } + + fun clear() { + writableDatabase.delete("notes", null, null) + } +} diff --git a/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/ProfileSerializer.kt b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/ProfileSerializer.kt new file mode 100644 index 00000000..ace06007 --- /dev/null +++ b/examples/native-storage-android/app/src/main/java/dev/mobile/nativestorage/android/ProfileSerializer.kt @@ -0,0 +1,92 @@ +package dev.mobile.nativestorage.android + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.CodedInputStream +import com.google.protobuf.CodedOutputStream +import com.google.protobuf.InvalidProtocolBufferException +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream + +data class Address(val city: String = "", val zip: String = "") + +data class Profile( + val name: String = "", + val age: Int = 0, + val premium: Boolean = false, + val tags: List = emptyList(), + val address: Address = Address(), +) + +/** + * Hand-written protobuf wire format, equivalent to: + * + * message Address { string city = 1; string zip = 2; } + * message Profile { + * string name = 1; int32 age = 2; bool premium = 3; + * repeated string tags = 4; Address address = 5; + * } + */ +object ProfileSerializer : Serializer { + private const val WIRE_TYPE_BITS = 3 + + override val defaultValue: Profile = Profile() + + override suspend fun readFrom(input: InputStream): Profile { + try { + val stream = CodedInputStream.newInstance(input) + var profile = Profile() + val tags = mutableListOf() + while (true) { + val tag = stream.readTag() + if (tag == 0) break + when (tag ushr WIRE_TYPE_BITS) { + 1 -> profile = profile.copy(name = stream.readStringRequireUtf8()) + 2 -> profile = profile.copy(age = stream.readInt32()) + 3 -> profile = profile.copy(premium = stream.readBool()) + 4 -> tags += stream.readStringRequireUtf8() + 5 -> profile = profile.copy(address = readAddress(stream.readByteArray())) + else -> if (!stream.skipField(tag)) break + } + } + return profile.copy(tags = tags) + } catch (e: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read profile.pb", e) + } + } + + override suspend fun writeTo(t: Profile, output: OutputStream) { + val stream = CodedOutputStream.newInstance(output) + if (t.name.isNotEmpty()) stream.writeString(1, t.name) + if (t.age != 0) stream.writeInt32(2, t.age) + if (t.premium) stream.writeBool(3, true) + t.tags.forEach { stream.writeString(4, it) } + if (t.address != Address()) stream.writeByteArray(5, writeAddress(t.address)) + stream.flush() // Do not close: DataStore owns the underlying stream. + } + + private fun writeAddress(address: Address): ByteArray { + val bytes = ByteArrayOutputStream() + val stream = CodedOutputStream.newInstance(bytes) + if (address.city.isNotEmpty()) stream.writeString(1, address.city) + if (address.zip.isNotEmpty()) stream.writeString(2, address.zip) + stream.flush() + return bytes.toByteArray() + } + + private fun readAddress(bytes: ByteArray): Address { + val stream = CodedInputStream.newInstance(bytes) + var address = Address() + while (true) { + val tag = stream.readTag() + if (tag == 0) break + when (tag ushr WIRE_TYPE_BITS) { + 1 -> address = address.copy(city = stream.readStringRequireUtf8()) + 2 -> address = address.copy(zip = stream.readStringRequireUtf8()) + else -> if (!stream.skipField(tag)) break + } + } + return address + } +} diff --git a/examples/native-storage-android/build.gradle.kts b/examples/native-storage-android/build.gradle.kts new file mode 100644 index 00000000..34f518ea --- /dev/null +++ b/examples/native-storage-android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false +} diff --git a/examples/native-storage-android/gradle.properties b/examples/native-storage-android/gradle.properties new file mode 100644 index 00000000..a0af07b5 --- /dev/null +++ b/examples/native-storage-android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +android.useAndroidX=true +kotlin.code.style=official diff --git a/examples/native-storage-android/gradle/wrapper/gradle-wrapper.jar b/examples/native-storage-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/examples/native-storage-android/gradle/wrapper/gradle-wrapper.properties b/examples/native-storage-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c61a118f --- /dev/null +++ b/examples/native-storage-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/native-storage-android/gradlew b/examples/native-storage-android/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/examples/native-storage-android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/native-storage-android/gradlew.bat b/examples/native-storage-android/gradlew.bat new file mode 100644 index 00000000..4626b902 --- /dev/null +++ b/examples/native-storage-android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/native-storage-android/run.sh b/examples/native-storage-android/run.sh new file mode 100755 index 00000000..e1441289 --- /dev/null +++ b/examples/native-storage-android/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build the debuggable Native Storage app, install it on one device, and launch it. +# Usage: ./run.sh +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +SERIAL="$1" +APP_ID="dev.mobile.nativestorage.android" +cd "$(dirname "$0")" + +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +ADB="$ANDROID_HOME/platform-tools/adb" +command -v "$ADB" >/dev/null 2>&1 || ADB="adb" + +./gradlew --console=plain :app:assembleRelease + +APK="app/build/outputs/apk/release/app-release.apk" +"$ADB" -s "$SERIAL" install -r "$APK" +"$ADB" -s "$SERIAL" shell am force-stop "$APP_ID" +"$ADB" -s "$SERIAL" shell am start -n "$APP_ID/.MainActivity" >/dev/null +echo "Launched $APP_ID on $SERIAL" diff --git a/examples/native-storage-android/settings.gradle.kts b/examples/native-storage-android/settings.gradle.kts new file mode 100644 index 00000000..4a879855 --- /dev/null +++ b/examples/native-storage-android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "native-storage-android" +include(":app") diff --git a/examples/native-storage-ios/.gitignore b/examples/native-storage-ios/.gitignore new file mode 100644 index 00000000..d94f3827 --- /dev/null +++ b/examples/native-storage-ios/.gitignore @@ -0,0 +1,2 @@ +.build/ +xcuserdata/ diff --git a/examples/native-storage-ios/NativeStorageTest.entitlements b/examples/native-storage-ios/NativeStorageTest.entitlements new file mode 100644 index 00000000..a8a211fd --- /dev/null +++ b/examples/native-storage-ios/NativeStorageTest.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.mobile.nativestoragetest + + + diff --git a/examples/native-storage-ios/NativeStorageTest.xcodeproj/project.pbxproj b/examples/native-storage-ios/NativeStorageTest.xcodeproj/project.pbxproj new file mode 100644 index 00000000..3d9941e0 --- /dev/null +++ b/examples/native-storage-ios/NativeStorageTest.xcodeproj/project.pbxproj @@ -0,0 +1,206 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 71; + objects = { + +/* Begin PBXFileReference section */ + A00000000000000000000006 /* NativeStorageTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = NativeStorageTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A00000000000000000000003 /* NativeStorageTest */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NativeStorageTest; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A00000000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A00000000000000000000002 = { + isa = PBXGroup; + children = ( + A00000000000000000000003 /* NativeStorageTest */, + A00000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + A00000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + A00000000000000000000006 /* NativeStorageTest.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A00000000000000000000005 /* NativeStorageTest */ = { + isa = PBXNativeTarget; + buildConfigurationList = A00000000000000000000011 /* Build configuration list for PBXNativeTarget "NativeStorageTest" */; + buildPhases = ( + A00000000000000000000007 /* Sources */, + A00000000000000000000008 /* Frameworks */, + A00000000000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A00000000000000000000003 /* NativeStorageTest */, + ); + name = NativeStorageTest; + productName = NativeStorageTest; + productReference = A00000000000000000000006 /* NativeStorageTest.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A00000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 2600; + }; + buildConfigurationList = A00000000000000000000010 /* Build configuration list for PBXProject "NativeStorageTest" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A00000000000000000000002; + productRefGroup = A00000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A00000000000000000000005 /* NativeStorageTest */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A00000000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A00000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A00000000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + A00000000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + A00000000000000000000014 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NativeStorageTest.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = LC6XSNN25D; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Native Storage"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.mobile.nativestoragetest; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A00000000000000000000015 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NativeStorageTest.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = LC6XSNN25D; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Native Storage"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.mobile.nativestoragetest; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A00000000000000000000010 /* Build configuration list for PBXProject "NativeStorageTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000012 /* Debug */, + A00000000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A00000000000000000000011 /* Build configuration list for PBXNativeTarget "NativeStorageTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000014 /* Debug */, + A00000000000000000000015 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A00000000000000000000001 /* Project object */; +} diff --git a/examples/native-storage-ios/NativeStorageTest.xcodeproj/xcshareddata/xcschemes/NativeStorageTest.xcscheme b/examples/native-storage-ios/NativeStorageTest.xcodeproj/xcshareddata/xcschemes/NativeStorageTest.xcscheme new file mode 100644 index 00000000..cdc82543 --- /dev/null +++ b/examples/native-storage-ios/NativeStorageTest.xcodeproj/xcshareddata/xcschemes/NativeStorageTest.xcscheme @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/native-storage-ios/NativeStorageTest/ContentView.swift b/examples/native-storage-ios/NativeStorageTest/ContentView.swift new file mode 100644 index 00000000..28bd72c4 --- /dev/null +++ b/examples/native-storage-ios/NativeStorageTest/ContentView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct ContentView: View { + @State private var summary: [(String, String)] = [] + + var body: some View { + NavigationStack { + List { + Section("Actions") { + action("Sign in", "UserDefaults: strings, numbers, dates, arrays, data, Codable") { Storage.signIn() } + action("Sign out", "Removes the sign-in keys") { Storage.signOut() } + action("Write App Group", "Shared UserDefaults and a widget file") { Storage.writeAppGroup() } + action("Add Core Data note", "Notes.sqlite in Application Support") { Storage.addNote() } + action("Add SwiftData task", "default.store in Application Support") { Storage.addTask() } + action("Write files", "JSON, binary plist, NSKeyedArchiver, text, cache") { Storage.writeFiles() } + } + Section("What the app reads") { + ForEach(summary, id: \.0) { row in + LabeledContent(row.0, value: row.1).font(.footnote) + } + } + } + .navigationTitle("Native Storage") + .onAppear { summary = Storage.summary() } + } + } + + private func action(_ title: String, _ detail: String, _ perform: @escaping @MainActor () -> Void) -> some View { + Button { + perform() + summary = Storage.summary() + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } + } +} diff --git a/examples/native-storage-ios/NativeStorageTest/NativeStorageTestApp.swift b/examples/native-storage-ios/NativeStorageTest/NativeStorageTestApp.swift new file mode 100644 index 00000000..abfdfd6e --- /dev/null +++ b/examples/native-storage-ios/NativeStorageTest/NativeStorageTestApp.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct NativeStorageTestApp: App { + var body: some Scene { + WindowGroup { ContentView() } + } +} diff --git a/examples/native-storage-ios/NativeStorageTest/Storage.swift b/examples/native-storage-ios/NativeStorageTest/Storage.swift new file mode 100644 index 00000000..6156c6bf --- /dev/null +++ b/examples/native-storage-ios/NativeStorageTest/Storage.swift @@ -0,0 +1,139 @@ +import CoreData +import Foundation +import SwiftData + +// Writes realistic data through each native storage API, so the Storage tool can be checked against real files. +// Every action runs from the UI, so the helpers live on the main actor. +@MainActor enum Storage { + static let appGroup = "group.dev.mobile.nativestoragetest" + static let fileManager = FileManager.default + static var documents: URL { fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] } + static var support: URL { + let url = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + static var caches: URL { fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] } + + // MARK: UserDefaults + + static func signIn() { + let defaults = UserDefaults.standard + defaults.set("Ada Lovelace", forKey: "user.name") + defaults.set(4211, forKey: "user.id") + defaults.set(true, forKey: "isLoggedIn") + defaults.set(Date(), forKey: "lastLogin") + defaults.set(["swift", "ios", "debugging"], forKey: "user.interests") + defaults.set(["plan": "pro", "seats": 3] as [String: Any], forKey: "subscription") + defaults.set(Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), forKey: "avatar.header") + defaults.set(try? JSONEncoder().encode(Session(token: "tok_live_1234", expiresAt: Date().addingTimeInterval(86_400))), forKey: "session") + } + + static func signOut() { + for key in ["user.name", "user.id", "isLoggedIn", "lastLogin", "user.interests", "subscription", "avatar.header", "session"] { + UserDefaults.standard.removeObject(forKey: key) + } + } + + // MARK: App Group (shared with widgets and extensions) + + static func writeAppGroup() { + let shared = UserDefaults(suiteName: appGroup) + shared?.set("Plan your week", forKey: "widget.headline") + shared?.set(3, forKey: "widget.unread") + if let container = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroup) { + try? Data("{\"items\":[\"Buy milk\",\"Call Ada\"]}".utf8).write(to: container.appendingPathComponent("widget-timeline.json")) + } + } + + // MARK: Core Data, with a model built in code so no .xcdatamodeld is needed + + static let coreData: NSPersistentContainer = { + let note = NSEntityDescription() + note.name = "Note" + note.managedObjectClassName = NSStringFromClass(NSManagedObject.self) + let title = NSAttributeDescription() + title.name = "title" + title.attributeType = .stringAttributeType + let created = NSAttributeDescription() + created.name = "createdAt" + created.attributeType = .dateAttributeType + let pinned = NSAttributeDescription() + pinned.name = "pinned" + pinned.attributeType = .booleanAttributeType + note.properties = [title, created, pinned] + let model = NSManagedObjectModel() + model.entities = [note] + let container = NSPersistentContainer(name: "Notes", managedObjectModel: model) + container.loadPersistentStores { _, error in if let error { print("Core Data failed: \(error)") } } + return container + }() + + static func addNote() { + let context = coreData.viewContext + let note = NSManagedObject(entity: coreData.managedObjectModel.entitiesByName["Note"]!, insertInto: context) + let count = (try? context.count(for: NSFetchRequest(entityName: "Note"))) ?? 0 + note.setValue("Note \(count)", forKey: "title") + note.setValue(Date(), forKey: "createdAt") + note.setValue(count % 2 == 0, forKey: "pinned") + try? context.save() + } + + // MARK: SwiftData, in its default.store + + static let swiftData: ModelContainer = try! ModelContainer(for: TodoItem.self) + + static func addTask() { + let context = swiftData.mainContext + let count = (try? context.fetchCount(FetchDescriptor())) ?? 0 + context.insert(TodoItem(title: "Task \(count + 1)", priority: count % 3, done: false)) + try? context.save() + } + + // MARK: Files a native app commonly writes + + static func writeFiles() { + let profile = Profile(name: "Ada Lovelace", email: "ada@example.com", roles: ["admin", "beta"], onboarding: .init(step: 3, completed: false)) + let json = JSONEncoder() + json.outputFormatting = [.prettyPrinted, .sortedKeys] + try? json.encode(profile).write(to: documents.appendingPathComponent("profile.json")) + + let plist = PropertyListEncoder() + plist.outputFormat = .binary + try? plist.encode(AppState(launches: 7, lastScreen: "settings", featureFlags: ["newOnboarding": true, "darkIcons": false])).write(to: support.appendingPathComponent("state.plist")) + + let archive = try? NSKeyedArchiver.archivedData(withRootObject: ["recentSearches": ["swiftui", "core data"], "scrollOffset": 240] as NSDictionary, requiringSecureCoding: false) + try? archive?.write(to: documents.appendingPathComponent("session.archive")) + + try? "Remember to test the logged-out flow.\n".write(to: documents.appendingPathComponent("notes.txt"), atomically: true, encoding: .utf8) + try? Data(repeating: 0xAB, count: 256 * 1024).write(to: caches.appendingPathComponent("image-cache.bin")) + } + + // MARK: Reading back what the app sees + + static func summary() -> [(String, String)] { + let defaults = UserDefaults.standard + let notes = (try? coreData.viewContext.count(for: NSFetchRequest(entityName: "Note"))) ?? 0 + let tasks = (try? swiftData.mainContext.fetchCount(FetchDescriptor())) ?? 0 + let files = ["profile.json", "session.archive", "notes.txt"].filter { fileManager.fileExists(atPath: documents.appendingPathComponent($0).path) } + return [ + ("UserDefaults user.name", defaults.string(forKey: "user.name") ?? "—"), + ("App Group widget.unread", UserDefaults(suiteName: appGroup)?.object(forKey: "widget.unread").map { "\($0)" } ?? "—"), + ("Core Data notes", "\(notes)"), + ("SwiftData tasks", "\(tasks)"), + ("Document files", files.isEmpty ? "—" : files.joined(separator: ", ")), + ] + } +} + +struct Session: Codable { var token: String; var expiresAt: Date } +struct Profile: Codable { var name: String; var email: String; var roles: [String]; var onboarding: Onboarding; struct Onboarding: Codable { var step: Int; var completed: Bool } } +struct AppState: Codable { var launches: Int; var lastScreen: String; var featureFlags: [String: Bool] } + +@Model final class TodoItem { + var title: String + var priority: Int + var done: Bool + var createdAt: Date + init(title: String, priority: Int, done: Bool) { self.title = title; self.priority = priority; self.done = done; self.createdAt = .now } +} diff --git a/examples/native-storage-ios/README.md b/examples/native-storage-ios/README.md new file mode 100644 index 00000000..9e8acb5f --- /dev/null +++ b/examples/native-storage-ios/README.md @@ -0,0 +1,35 @@ +# Native Storage iOS test app + +A dependency-free SwiftUI app that writes realistic data through the standard iOS +storage APIs. It is a fixture for the plugin's app storage reader. Requires Xcode 26+ +and iOS 18+. + +## Run + +Boot an iOS simulator, then pass its UDID from the repository root: + +```sh +./examples/native-storage-ios/run.sh +``` + +The script builds Debug, installs, and launches on that simulator. It signs ad hoc +so the App Group entitlement is embedded; the simulator only creates the group +container for a signed entitlement. Build output stays in the ignored `.build/` +directory. + +- Bundle ID: `dev.mobile.nativestoragetest` +- App Group: `group.dev.mobile.nativestoragetest` + +## What each button writes + +| Button | Location | Data | +| --- | --- | --- | +| Sign in | `Library/Preferences/dev.mobile.nativestoragetest.plist` | UserDefaults strings, numbers, a date, an array, a dictionary, raw bytes, and a Codable value | +| Sign out | same file | Removes the keys above | +| Write App Group | the App Group container | Shared UserDefaults and `widget-timeline.json` | +| Add Core Data note | `Library/Application Support/Notes.sqlite` | One `Note` row, from a model built in code | +| Add SwiftData task | `Library/Application Support/default.store` in the App Group container | One `TodoItem` row; SwiftData uses the group container when the app has an App Group | +| Write files | `Documents/` and `Library/` | `profile.json` (Codable JSON), `state.plist` (binary plist), `session.archive` (NSKeyedArchiver), `notes.txt`, and a 256 KB cache file | + +The list under the buttons shows what the app reads back, so a cleared store shows +up as missing in the app too. diff --git a/examples/native-storage-ios/run.sh b/examples/native-storage-ios/run.sh new file mode 100755 index 00000000..729c3bda --- /dev/null +++ b/examples/native-storage-ios/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +app_dir=$(cd "$(dirname "$0")" && pwd) +device_id=$1 +build_dir="$app_dir/.build" + +# Ad-hoc signing embeds the App Group entitlement, which the simulator needs to create the group container. +xcodebuild \ + -project "$app_dir/NativeStorageTest.xcodeproj" \ + -scheme NativeStorageTest \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$device_id" \ + -derivedDataPath "$build_dir" \ + CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= \ + build + +xcrun simctl install "$device_id" "$build_dir/Build/Products/Debug-iphonesimulator/NativeStorageTest.app" +xcrun simctl launch --terminate-running-process "$device_id" dev.mobile.nativestoragetest From fced2a4615f4c761432fa37032feb8c487a9eef9 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 21 Oct 2026 15:01:49 +0200 Subject: [PATCH 3/4] chore(release): bump version to 0.1.162 --- .codex-plugin/plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d226f43f..c753eda0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.161", + "version": "0.1.162", "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 218b6f48..523fb789 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.161", + "version": "0.1.162", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.161", + "version": "0.1.162", "dependencies": { "@base-ui/react": "^1.8.0", "@callstack/tracesift-explore": "^0.5.0", diff --git a/package.json b/package.json index 58f7c55f..072289fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.161", + "version": "0.1.162", "private": true, "type": "module", "engines": { From 7c14d2706db62cfb5140aceb536e84024d69aeda Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 21 Oct 2026 15:07:45 +0200 Subject: [PATCH 4/4] refactor(storage): simplify native storage reading and panel details - Merge the panel's table and file slots into one detail, loaded by one helper that drops a response once a newer detail replaced it; this also fixes a slower earlier table page overwriting the page opened after it. - Describe every SQLite table with one query, replacing the table list and the per-table PRAGMA. - Make Proto DataStore its own store kind instead of re-checking the file name. - Report files too large to preview as their own format. - Move varint, UTF-8, and JSON helpers into encoding.ts, share the Apple date conversion, and drop a cast, an export, and duplicate operation types. --- src/server/app-storage/container.ts | 2 +- src/server/app-storage/encoding.ts | 30 ++++++++++++ src/server/app-storage/formats.ts | 21 ++++----- src/server/app-storage/mmkv.ts | 31 +------------ src/server/app-storage/preview.ts | 68 ++++++++++++---------------- src/server/app-storage/reader.ts | 51 +++++++++++---------- src/server/app-storage/tools.ts | 4 +- src/server/telemetry.ts | 3 +- src/shared/app-storage.ts | 12 +++-- src/ui/components/storage-browse.tsx | 41 +++++++++-------- src/ui/components/storage-parts.tsx | 1 + src/ui/storage-panel.ts | 67 ++++++++++++++------------- tests/native-storage.test.ts | 22 ++++++++- 13 files changed, 187 insertions(+), 166 deletions(-) create mode 100644 src/server/app-storage/encoding.ts diff --git a/src/server/app-storage/container.ts b/src/server/app-storage/container.ts index 3791372e..4886ca85 100644 --- a/src/server/app-storage/container.ts +++ b/src/server/app-storage/container.ts @@ -30,7 +30,7 @@ function safePath(path: string) { } // Paths under this prefix live in one of the app's App Group containers, shared with its widgets and extensions. -export const appGroupPrefix = "AppGroup/"; +const appGroupPrefix = "AppGroup/"; const preferencesPlist = /(?:^|\/)Library\/Preferences\/[^/]+\.plist$/; const containerMetadata = ".com.apple.mobile_container_manager.metadata.plist"; diff --git a/src/server/app-storage/encoding.ts b/src/server/app-storage/encoding.ts new file mode 100644 index 00000000..083654c6 --- /dev/null +++ b/src/server/app-storage/encoding.ts @@ -0,0 +1,30 @@ +// Byte-level helpers shared by the MMKV, protobuf, and file decoders. + +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; +} + +const utf8 = new TextDecoder("utf-8", { fatal: true }); +// Bytes as text, or undefined when they are not valid UTF-8 or hold control characters other than whitespace, +// which means binary data that happens to decode. +export function utf8Text(bytes: Buffer): string | undefined { + try { + const value = utf8.decode(bytes); + 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; } +} diff --git a/src/server/app-storage/formats.ts b/src/server/app-storage/formats.ts index 5b8173b1..6d36bef5 100644 --- a/src/server/app-storage/formats.ts +++ b/src/server/app-storage/formats.ts @@ -1,5 +1,5 @@ import type { EntryType, StoreKind } from "../../shared/app-storage.ts"; -import { jsonType, readVarint } from "./mmkv.ts"; +import { jsonType, readVarint, utf8Text } from "./encoding.ts"; export type ParsedEntry = { key: string; type: EntryType; value: string; bytes: number; guessed?: boolean }; @@ -202,10 +202,6 @@ export function parseDataStorePreferences(buffer: Buffer): ParsedEntry[] { // ---- Proto DataStore and other schema-less protobuf: field numbers become keys, and value types are guessed. -const printable = (bytes: Buffer) => { - const text = bytes.toString("utf8"); - return Buffer.from(text, "utf8").equals(bytes) && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffd]/.test(text) ? text : undefined; -}; const message = (bytes: Buffer) => { try { const parsed = fields(bytes); return parsed.length > 0 ? parsed : undefined; } catch { return undefined; } }; // Short text often also parses as protobuf, so text wins over a nested message. @@ -222,7 +218,7 @@ export function protobufEntries(buffer: Buffer, prefix = ""): ParsedEntry[] { const bytes = field.bytes!; if (field.wire === 5) return entry("number", String(bytes.readFloatLE(0)), 4); if (field.wire === 1) return entry("number", String(bytes.readDoubleLE(0)), 8); - const text = printable(bytes); + const text = utf8Text(bytes); if (text !== undefined) return [{ ...textEntry(key, text, bytes.length), guessed: true }]; if (message(bytes)) return protobufEntries(bytes, `${key}.`); return entry("data", bytes.toString("hex"), bytes.length); @@ -269,7 +265,7 @@ export function classifyFiles(paths: readonly string[], platform: "ios" | "andro continue; } if (name.endsWith(".pb") && basename(directory) === "datastore") { - stores.push({ kind: "datastore", name: name.slice(0, -3), path, files: [path] }); + stores.push({ kind: "proto-datastore", name: name.slice(0, -3), path, files: [path] }); continue; } if (name.endsWith(".realm")) { @@ -277,11 +273,12 @@ export function classifyFiles(paths: readonly string[], platform: "ios" | "andro stores.push({ kind: "realm", name: name.slice(0, -6), path, files: [path, ...related] }); continue; } - // SwiftData's default.store and custom-named Core Data stores are SQLite too; a -wal or -shm file next to any - // file marks it as a database in WAL mode. - const sqliteName = platform === "android" ? directory === "databases" && !sqliteSuffix.test(name) && !name.endsWith(".bak") : /\.(?:db|sqlite3?|sql|store)$/i.test(name); - const walCompanion = !sqliteSuffix.test(name) && (all.has(`${path}-wal`) || all.has(`${path}-shm`)); - if ((sqliteName || walCompanion || /\.(?:db|sqlite3?)$/i.test(name)) && !systemPaths.test(path)) { + // Android keeps databases under databases/ without fixed extensions. On iOS, SwiftData uses default.store, and a + // -wal or -shm file beside any file marks a database in WAL mode, such as a Core Data store with a custom name. + const sqlite = !sqliteSuffix.test(name) && ( + (platform === "android" ? (directory === "databases" && !name.endsWith(".bak")) || /\.(?:db|sqlite3?)$/i.test(name) : /\.(?:db|sqlite3?|sql|store)$/i.test(name)) + || all.has(`${path}-wal`) || all.has(`${path}-shm`)); + if (sqlite && !systemPaths.test(path)) { stores.push({ kind: "sqlite", name, path, files: [path, ...["-wal", "-shm", "-journal"].map(suffix => path + suffix).filter(file => all.has(file))] }); } } diff --git a/src/server/app-storage/mmkv.ts b/src/server/app-storage/mmkv.ts index bf229e62..9eed92f2 100644 --- a/src/server/app-storage/mmkv.ts +++ b/src/server/app-storage/mmkv.ts @@ -1,5 +1,6 @@ import { crc32 } from "node:zlib"; import type { EntryType } from "../../shared/app-storage.ts"; +import { jsonType, readVarint, utf8Text } from "./encoding.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 @@ -19,19 +20,6 @@ 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"); @@ -81,28 +69,13 @@ export function decodeMmkv(data: Buffer, meta?: Buffer): MmkvFile { 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); + const string = utf8Text(payload); if (string !== undefined) return { type: jsonType(string) ?? "string", value: string }; return { type: "data", value: payload.toString("hex") }; } diff --git a/src/server/app-storage/preview.ts b/src/server/app-storage/preview.ts index 5c966951..831f9d58 100644 --- a/src/server/app-storage/preview.ts +++ b/src/server/app-storage/preview.ts @@ -1,22 +1,16 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { appleDate } from "../../shared/app-storage.ts"; import type { StorageFilePreview } from "../../shared/app-storage.ts"; import { withTemporaryDirectory } from "../app-control/commands.ts"; import type { CommandRunner } from "../app-control/commands.ts"; import type { AppContainer } from "./container.ts"; import { parsePlistValue, plainPlist } from "./formats.ts"; +import { utf8Text } from "./encoding.ts"; type PreviewOptions = { maxBytes: number; maxLength: number; hexBytes: number; run: CommandRunner }; -const utf8 = new TextDecoder("utf-8", { fatal: true }); -function text(bytes: Buffer) { - try { - const value = utf8.decode(bytes); - return /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value) ? undefined : value; - } catch { return undefined; } -} - const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const uid = (value: unknown) => isRecord(value) && Object.keys(value).length === 1 && typeof value["CF$UID"] === "number" ? value["CF$UID"] : undefined; @@ -33,52 +27,50 @@ export function unarchive(archive: Record): unknown { } if (Array.isArray(value)) return value.map(item => resolve(item, path)); if (!isRecord(value)) return value; - if (Array.isArray(value["NS.keys"]) && Array.isArray(value["NS.objects"])) { - const values = value["NS.objects"]; - return Object.fromEntries((value["NS.keys"] as unknown[]).map((key, index) => [String(resolve(key, path)), resolve(values[index], path)])); - } - if (Array.isArray(value["NS.objects"])) return resolve(value["NS.objects"], path); - if (typeof value["NS.string"] === "string") return value["NS.string"]; - // NSDate stores seconds since 2001-01-01. - if (typeof value["NS.time"] === "number") return new Date((value["NS.time"] + 978307200) * 1000).toISOString(); - if (value["NS.data"] !== undefined) return value["NS.data"]; + const { "NS.keys": keys, "NS.objects": values, "NS.string": string, "NS.time": time, "NS.data": data } = value; + if (Array.isArray(keys) && Array.isArray(values)) return Object.fromEntries(keys.map((key, index) => [String(resolve(key, path)), resolve(values[index], path)])); + if (Array.isArray(values)) return resolve(values, path); + if (typeof string === "string") return string; + if (typeof time === "number") return appleDate(time); + if (data !== undefined) return data; return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$class").map(([key, child]) => [key, resolve(child, path)])); }; const top = isRecord(archive.$top) ? archive.$top : {}; return resolve("root" in top ? top.root : top, new Set()); } -async function plistXml(bytes: Buffer, run: CommandRunner) { - if (bytes.subarray(0, 6).toString("latin1") !== "bplist") return bytes.toString("utf8"); - if (process.platform !== "darwin") return undefined; - return withTemporaryDirectory(async directory => { - const file = join(directory, "preview.plist"); - await writeFile(file, bytes); - return run("plutil", ["-convert", "xml1", "-o", "-", file], { timeout: 10000, maxBuffer: 64 * 1024 * 1024 }); - }); +// A property list as plain JSON. Binary plists need plutil, which only macOS has. +async function plistJson(bytes: Buffer, run: CommandRunner): Promise { + const binary = bytes.subarray(0, 6).toString("latin1") === "bplist"; + if (binary && process.platform !== "darwin") return undefined; + try { + const xml = binary ? await withTemporaryDirectory(async directory => { + const file = join(directory, "preview.plist"); + await writeFile(file, bytes); + return run("plutil", ["-convert", "xml1", "-o", "-", file], { timeout: 10000, maxBuffer: 64 * 1024 * 1024 }); + }) : bytes.toString("utf8"); + return plainPlist(parsePlistValue(xml)); + } catch { return undefined; } } // A file from the app's data directory, decoded by its contents rather than its name. export async function previewFile(container: AppContainer, path: string, { maxBytes, maxLength, hexBytes, run }: PreviewOptions): Promise { const file = (await container.list()).find(item => item.path === path); if (file === undefined) throw new ExpectedOperationError("invalid_input", "That file no longer exists. Refresh and try again."); - const shown = (format: StorageFilePreview["format"], content: string): StorageFilePreview => ({ - path, bytes: file.bytes, format, content: content.length > maxLength ? content.slice(0, maxLength) : content, truncated: content.length > maxLength, - }); - if (file.bytes > maxBytes) return { path, bytes: file.bytes, format: "binary", content: "", truncated: true }; + const result = (format: StorageFilePreview["format"], content: string, truncated = content.length > maxLength): StorageFilePreview => + ({ path, bytes: file.bytes, format, content: content.slice(0, maxLength), truncated }); + if (file.bytes > maxBytes) return result("too-large", "", true); const bytes = await container.read(path); const head = bytes.subarray(0, 64).toString("latin1"); if (head.startsWith("bplist") || (head.startsWith(" undefined); - const value = xml === undefined ? undefined : (() => { try { return plainPlist(parsePlistValue(xml)); } catch { return undefined; } })(); - if (isRecord(value) && value.$archiver === "NSKeyedArchiver") return shown("archive", JSON.stringify(unarchive(value), null, 2)); - if (value !== undefined) return shown("plist", JSON.stringify(value, null, 2)); + const value = await plistJson(bytes, run); + if (isRecord(value) && value.$archiver === "NSKeyedArchiver") return result("archive", JSON.stringify(unarchive(value), null, 2)); + if (value !== undefined) return result("plist", JSON.stringify(value, null, 2)); } - const content = text(bytes); + const content = utf8Text(bytes); if (content !== undefined) { - try { return shown("json", JSON.stringify(JSON.parse(content), null, 2)); } - catch { return shown("text", content); } + try { return result("json", JSON.stringify(JSON.parse(content), null, 2)); } + catch { return result("text", content); } } - const hex = bytes.subarray(0, hexBytes).toString("hex"); - return { ...shown("binary", hex), truncated: bytes.length > hexBytes }; + return result("binary", bytes.subarray(0, hexBytes).toString("hex"), bytes.length > hexBytes); } diff --git a/src/server/app-storage/reader.ts b/src/server/app-storage/reader.ts index 5033ebb8..17bef0cd 100644 --- a/src/server/app-storage/reader.ts +++ b/src/server/app-storage/reader.ts @@ -24,8 +24,17 @@ async function sqlite(run: CommandRunner, file: string, sql: string): Promise - (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)); +type Column = StorageTable["columns"][number]; +// Every table's columns in one query, by table name. A SQLite table always has a column, so this lists every table. +async function describeTables(run: CommandRunner, file: string): Promise> { + const tables = new Map(); + for (const row of await sqlite(run, file, "SELECT m.name AS tbl, p.name AS col, p.type AS type FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid")) { + let columns = tables.get(String(row.tbl)); + if (columns === undefined) tables.set(String(row.tbl), columns = []); + columns.push({ name: String(row.col), type: String(row.type ?? "") }); + } + return tables; +} // sqlite3 reads a local copy that includes the write-ahead log, so writes SQLite has not checkpointed are read too. function withDatabase(container: AppContainer, candidate: StoreCandidate, work: (file: string) => Promise): Promise { @@ -39,27 +48,23 @@ function withDatabase(container: AppContainer, candidate: StoreCandidate, wor // table named Storage, and v3 an `entries` table under async-storage/databases/. async function readSqlite(container: AppContainer, candidate: StoreCandidate, run: CommandRunner): Promise { return withDatabase(container, candidate, async file => { - let tables: string[]; - try { tables = await listTables(run, file); } + let tables: Map; + try { tables = await describeTables(run, file); } 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; + const keyValueTable = tables.has("catalystLocalStorage") ? "catalystLocalStorage" + : tables.has("entries") && candidate.path.includes("async-storage/") ? "entries" + : tables.has("Storage") && /^AsyncStorage/.test(candidate.name) ? "Storage" : undefined; if (keyValueTable) { const rows = await sqlite(run, file, `SELECT key, value FROM ${quoteIdentifier(keyValueTable)}`); const entries = rows.map(row => row.value === null || row.value === undefined ? nullEntry(String(row.key)) : textEntry(String(row.key), String(row.value))); return { status: "ok", kind: "async-storage", entries }; } - if (tables.length === 0) return { status: "ok", entries: [] }; - // Two queries cover every table. Running one sqlite3 per table in parallel let the first process recover the + if (tables.size === 0) return { status: "ok", entries: [] }; + // One query counts every table. Running one sqlite3 per table in parallel let the first process recover the // write-ahead log while the others failed on its lock. - const counts = new Map((await sqlite(run, file, tables.map(table => `SELECT ${sqlString(table)} AS name, count(*) AS total FROM ${quoteIdentifier(table)}`).join(" UNION ALL "))) + const counts = new Map((await sqlite(run, file, [...tables.keys()].map(table => `SELECT ${sqlString(table)} AS name, count(*) AS total FROM ${quoteIdentifier(table)}`).join(" UNION ALL "))) .map(row => [String(row.name), Number(row.total)])); - const columns = new Map(); - for (const row of await sqlite(run, file, "SELECT m.name AS tbl, p.name AS col FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid")) { - columns.set(String(row.tbl), [...columns.get(String(row.tbl)) ?? [], String(row.col)]); - } - return { status: "ok", entries: tables.map(table => ({ key: table, type: "table", value: `${counts.get(table) ?? "?"} rows · ${(columns.get(table) ?? []).join(", ")}`, bytes: 0 })) }; + return { status: "ok", entries: [...tables].map(([table, columns]) => ({ key: table, type: "table", value: `${counts.get(table) ?? "?"} rows · ${columns.map(column => column.name).join(", ")}`, bytes: 0 })) }; }); } @@ -103,11 +108,10 @@ async function readStore(container: AppContainer, candidate: StoreCandidate, run } return { status: "ok", entries }; } - case "datastore": { - const bytes = await container.read(candidate.path); - if (candidate.path.endsWith(".preferences_pb")) return { status: "ok", entries: parseDataStorePreferences(bytes) }; - return { status: "ok", note: "Proto DataStore has no schema in the file, so keys are field numbers and types are guessed.", entries: protobufEntries(bytes) }; - } + case "datastore": + return { status: "ok", entries: parseDataStorePreferences(await container.read(candidate.path)) }; + case "proto-datastore": + return { status: "ok", note: "The file has no schema, so keys are field numbers and types are guessed.", entries: protobufEntries(await container.read(candidate.path)) }; case "async-storage": return readAsyncManifest(container, candidate); case "sqlite": @@ -180,16 +184,15 @@ export async function clearStore(container: AppContainer, id: string) { export async function readTable(container: AppContainer, id: string, table: string, { offset, limit, valueLength, run }: { offset: number; limit: number; valueLength: number; run: CommandRunner }): Promise { const candidate = await findStore(container, id, "sqlite"); return withDatabase(container, candidate, async file => { - if (!(await listTables(run, file)).includes(table)) throw new ExpectedOperationError("invalid_input", `The table ${table} no longer exists. Refresh and try again.`); + const columns = (await describeTables(run, file)).get(table); + if (columns === undefined) throw new ExpectedOperationError("invalid_input", `The table ${table} no longer exists. Refresh and try again.`); const name = quoteIdentifier(table); - const info = await sqlite(run, file, `PRAGMA table_info(${name})`); const [count] = await sqlite(run, file, `SELECT count(*) AS total FROM ${name}`); - const columns = info.map(column => ({ name: String(column.name), type: String(column.type ?? "") })); // Blobs cannot travel as JSON, so they become a hex preview no longer than the value limit. const select = columns.map((column, index) => { const quoted = quoteIdentifier(column.name); return `CASE typeof(${quoted}) WHEN 'blob' THEN 'x''' || substr(hex(${quoted}), 1, ${valueLength}) || '''' ELSE ${quoted} END AS c${index}`; - }).join(", ") || "NULL AS c0"; + }).join(", "); const rows = await sqlite(run, file, `SELECT ${select} FROM ${name} LIMIT ${limit} OFFSET ${offset}`); return { table, columns, offset, total: Number(count?.total ?? 0), diff --git a/src/server/app-storage/tools.ts b/src/server/app-storage/tools.ts index 46721b70..69f9318b 100644 --- a/src/server/app-storage/tools.ts +++ b/src/server/app-storage/tools.ts @@ -2,6 +2,7 @@ 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 type { AppStorageOperation } from "../telemetry.ts"; import { errorMessage } from "../../shared/protocol.ts"; import { expectedOutcome } from "../../shared/error-reporting.ts"; import { appTargetShape, bundleIdSchema } from "../../shared/app-control.ts"; @@ -17,7 +18,6 @@ import { clearStore, readStorage, readTable } from "./reader.ts"; import { previewFile } from "./preview.ts"; type Target = { platform: "ios" | "android"; deviceId: string }; -type Operation = "read" | "clear" | "reset" | "table" | "file"; export type ContainerFactory = (target: Target, bundleId: string) => Promise; export function createContainers(commands: Commands = systemCommands): ContainerFactory { @@ -32,7 +32,7 @@ const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: fa export function registerAppStorageTools(server: McpServer, commands: Commands = systemCommands, driverFor: DriverFactory = createDrivers(commands), containerFor: ContainerFactory = createContainers(commands)) { // Every operation opens the app's container on a validated device and reports one timing. - async function run(operation: Operation, { platform, deviceId, bundleId }: Target & { bundleId: string }, work: (container: AppContainer, driver: AppDriver) => Promise>) { + async function run(operation: AppStorageOperation, { platform, deviceId, bundleId }: Target & { bundleId: string }, work: (container: AppContainer, driver: AppDriver) => Promise>) { const startedAt = performance.now(); try { // Resolving the driver validates the device ID before any command runs. diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts index a717d799..34225961 100644 --- a/src/server/telemetry.ts +++ b/src/server/telemetry.ts @@ -27,7 +27,8 @@ 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" | "table" | "file", platform: "ios" | "android", outcome: "success" | "failed", duration: number) { +export type AppStorageOperation = "read" | "clear" | "reset" | "table" | "file"; +export function recordAppStorageOperation(operation: AppStorageOperation, 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 }); diff --git a/src/shared/app-storage.ts b/src/shared/app-storage.ts index 3b3daf23..3b27b701 100644 --- a/src/shared/app-storage.ts +++ b/src/shared/app-storage.ts @@ -1,11 +1,11 @@ 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 const storeKinds = ["mmkv", "async-storage", "user-defaults", "shared-preferences", "datastore", "proto-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": "SQLite", "realm": "Realm", + "datastore": "DataStore", "proto-datastore": "Proto DataStore", "sqlite": "SQLite", "realm": "Realm", }; export const entryTypes = ["string", "json", "number", "integer", "boolean", "date", "data", "array", "object", "null", "table"] as const; @@ -69,8 +69,9 @@ export const storageTableSchema = z.object({ }); export type StorageTable = z.infer; -// One file's contents. json, plist, and archive (NSKeyedArchiver) arrive as JSON text; binary as hex of its first bytes. -export const filePreviewFormats = ["json", "plist", "archive", "text", "binary"] as const; +// One file's contents. json, plist, and archive (NSKeyedArchiver) arrive as JSON text, binary as hex of its first +// bytes, and too-large files without content. +export const filePreviewFormats = ["json", "plist", "archive", "text", "binary", "too-large"] as const; export const storageFileSchema = z.object({ path: z.string(), bytes: z.number().int().nonnegative(), @@ -79,3 +80,6 @@ export const storageFileSchema = z.object({ truncated: z.boolean(), }); export type StorageFilePreview = z.infer; + +// Apple frameworks (Core Data, SwiftData, NSDate) count seconds from 2001-01-01. +export const appleDate = (seconds: number) => new Date((seconds + 978307200) * 1000).toISOString(); diff --git a/src/ui/components/storage-browse.tsx b/src/ui/components/storage-browse.tsx index 408a5845..27bedc30 100644 --- a/src/ui/components/storage-browse.tsx +++ b/src/ui/components/storage-browse.tsx @@ -1,7 +1,7 @@ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, LockIcon, PuzzleIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; import { useMemo, useState, useSyncExternalStore } from "react"; import type { ReactNode } from "react"; -import { storageLimits, storeKindLabels, storeKinds } from "../../shared/app-storage.ts"; +import { appleDate, storageLimits, storeKindLabels, storeKinds } from "../../shared/app-storage.ts"; import type { StorageEntry, StorageFilePreview, StorageSnapshot, StorageStore } from "../../shared/app-storage.ts"; import { isWorking } from "../storage-panel.ts"; import type { StorageFileView, StoragePanel, StorageTableView } from "../storage-panel.ts"; @@ -19,7 +19,7 @@ const entryId = (store: StorageStore, key: string) => `${store.id}\u0000${key}`; // The header above a store or the file list. Opening a table or file swaps the icon for a back button of the // same size and adds the item's name to the title. -function DetailHeader({ icon, title, child, subtitle, path, back, loading, children }: { icon: ReactNode; title: string; child?: string; subtitle: string; path: string; back?: { label: string; onBack: () => void }; loading?: boolean; children?: ReactNode }) { +function DetailHeader({ icon, title, child, subtitle, path, back, loading, children }: { icon?: ReactNode; title: string; child?: string; subtitle: string; path?: string; back?: { label: string; onBack: () => void }; loading?: boolean; children?: ReactNode }) { return

{back ? : icon}
@@ -34,21 +34,23 @@ function DetailHeader({ icon, title, child, subtitle, path, back, loading, child } const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; -const formatLabels: Record = { json: "JSON", plist: "Property list", archive: "NSKeyedArchiver archive", text: "Text", binary: "Binary" }; +const formatLabels: Record = { json: "JSON", plist: "Property list", archive: "NSKeyedArchiver archive", text: "Text", binary: "Binary", "too-large": "Too large to preview" }; +const parseJsonText = (text: string): unknown => { try { return JSON.parse(text); } catch { return undefined; } }; // A file's contents: structured formats as a JSON tree, text as is, and other bytes as hex. function FilePreview({ file }: { file: StorageFileView }) { const data = file.data; let body: ReactNode = null; - if (data && data.format === "binary") { - body = data.content ?
{data.content.replace(/(..)(?!$)/g, "$1 ")}
: null; - } else if (data) { - const parsed = data.format === "text" ? undefined : (() => { try { return JSON.parse(data.content) as unknown; } catch { return undefined; } })(); + let note = ""; + if (data?.format === "binary") { + body =
{data.content.replace(/(..)(?!$)/g, "$1 ")}
; + if (data.truncated) note = `Showing the first ${formatBytes(data.content.length / 2)} as hex.`; + } else if (data && data.format !== "too-large") { + // Structured formats arrive as JSON text; a cut-short one no longer parses and shows as text. + const parsed = data.format === "text" ? undefined : parseJsonText(data.content); body = parsed !== undefined ? :
{data.content}
; + if (data.truncated) note = "Cut short; the file is longer."; } - const note = !data ? "" : data.format === "binary" && !data.content ? `Too large to preview (${formatBytes(data.bytes)}).` - : data.format === "binary" && data.truncated ? `Showing the first ${formatBytes(data.content.length / 2)} as hex.` - : data.truncated ? "Cut short; the file is longer." : ""; return
{file.error && {file.error}} {note &&

{note}

} @@ -75,13 +77,13 @@ function FilesView({ panel, snapshot, file }: { panel: StoragePanel; snapshot: S const listed = snapshot.files.find(item => item.path === file.path); const subtitle = file.data ? `${formatLabels[file.data.format]} · ${formatBytes(file.data.bytes)}` : listed ? formatBytes(listed.bytes) : "Loading…"; return
- panel.closeFile() }} loading={file.loading} /> + panel.closeDetail() }} loading={file.loading} />
; } const breakdown = containerBreakdown(snapshot); return
- } title="Files" subtitle={`${plural(snapshot.fileCount, "file")} · ${formatBytes(snapshot.totalBytes)} in the app's data directory`} path="" /> + } title="Files" subtitle={`${plural(snapshot.fileCount, "file")} · ${formatBytes(snapshot.totalBytes)} in the app's data directory`} />
{breakdown.map(([label, bytes, tone]) => )}
@@ -117,9 +119,8 @@ function SearchResults({ groups, query, title, expansion }: { groups: { store: S
; } -// Core Data and SwiftData store dates in TIMESTAMP columns as seconds since 2001-01-01. -const appleDate = (type: string, cell: string | number) => typeof cell === "number" && /^timestamp$/i.test(type) - ? new Date((cell + 978307200) * 1000).toISOString() : undefined; +// Core Data and SwiftData keep dates in TIMESTAMP columns as seconds since 2001-01-01. +const timestamp = (type: string, cell: string | number) => typeof cell === "number" && /^timestamp$/i.test(type) ? appleDate(cell) : undefined; // One page of a SQLite table as a grid. Long cells are cut short and show in full on hover. function TableGrid({ table }: { table: StorageTableView }) { @@ -135,7 +136,7 @@ function TableGrid({ table }: { table: StorageTableView }) { {data.rows.map((row, index) => {row.map((cell, column) => { - const date = cell === null ? undefined : appleDate(data.columns[column].type, cell); + const date = cell === null ? undefined : timestamp(data.columns[column].type, cell); const tone = date ? "" : typeof cell === "number" ? "text-blue-600 tabular-nums dark:text-blue-400" : cell === null || /^x'/.test(cell) ? "text-muted-foreground" : ""; return {cell === null ? NULL : date ?? String(cell)} @@ -169,7 +170,7 @@ function StoreDetail({ panel, store, title, table, busy, expansion }: { panel: S : `${storeKindLabels[store.kind]} · ${plural(store.entryCount, store.kind === "sqlite" ? "table" : "key")} · ${formatBytes(store.bytes)}`; return
} title={title} child={table?.name} subtitle={subtitle} path={store.path} - back={table && { label: "Back to tables", onBack: () => panel.closeTable() }} loading={table?.loading}> + back={table && { label: "Back to tables", onBack: () => panel.closeDetail() }} loading={table?.loading}> {table && } {store.status === "ok" && store.kind !== "sqlite" && } void panel.clear(store.id)} @@ -201,7 +202,7 @@ export function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePan const store = snapshot.stores.find(item => item.id === selected) ?? (selected === FILES ? undefined : own[0] ?? sdks[0]); const expansion: Expansion = { expanded, copy, toggle: (item, key) => setExpanded(expanded === entryId(item, key) ? undefined : entryId(item, key)) }; const visible = (item: StorageStore) => !query || (matches.get(item.id)?.length ?? 0) > 0; - const select = (id: string) => { setSelected(id); setExpanded(undefined); panel.closeTable(); panel.closeFile(); }; + const select = (id: string) => { setSelected(id); setExpanded(undefined); panel.closeDetail(); }; const storeRow = (item: StorageStore) => } title={title(item)} subtitle={storeKindLabels[item.kind]} tooltip={item.path} selected={!query && store?.id === item.id} onSelect={() => select(item.id)} @@ -233,7 +234,7 @@ export function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePan
; const main = query ? ({ store: item, entries: matches.get(item.id) ?? [] })).filter(group => group.entries.length > 0)} query={query} title={title} expansion={expansion} /> - : store ? - : ; + : store ? + : ; return
{sidebar}
{picker}{main}
; } diff --git a/src/ui/components/storage-parts.tsx b/src/ui/components/storage-parts.tsx index 807d2292..c2ac0db3 100644 --- a/src/ui/components/storage-parts.tsx +++ b/src/ui/components/storage-parts.tsx @@ -20,6 +20,7 @@ const kindLook: Record = { "user-defaults": { ...chipColors.sky, icon: LayersIcon }, "shared-preferences": { ...chipColors.sky, icon: LayersIcon }, "datastore": { ...chipColors.sky, icon: LayersIcon }, + "proto-datastore": { ...chipColors.sky, icon: LayersIcon }, "mmkv": { ...chipColors.amber, icon: LayersIcon }, "async-storage": { ...chipColors.violet, icon: BracesIcon }, "sqlite": { ...chipColors.emerald, icon: DatabaseIcon }, diff --git a/src/ui/storage-panel.ts b/src/ui/storage-panel.ts index 7cf30997..003754c2 100644 --- a/src/ui/storage-panel.ts +++ b/src/ui/storage-panel.ts @@ -24,8 +24,11 @@ export type StorageChangeEvent = { before?: StorageEntry; after?: StorageEntry; }; -export type StorageTableView = { storeId: string; name: string; data?: StorageTable; loading: boolean; error: string }; -export type StorageFileView = { path: string; data?: StorageFilePreview; loading: boolean; error: string }; +// A table or file opened from Browse. Only one is open at a time, and its data arrives after it opens. +type DetailLoad = { loading: boolean; error: string }; +export type StorageTableView = DetailLoad & { kind: "table"; storeId: string; name: string; data?: StorageTable }; +export type StorageFileView = DetailLoad & { kind: "file"; path: string; data?: StorageFilePreview }; +export type StorageDetail = StorageTableView | StorageFileView; export type StorageState = { open: boolean; available: boolean; @@ -42,10 +45,7 @@ export type StorageState = { changes: readonly StorageChangeEvent[]; // SDK stores (analytics, crash reporting) write often, so their changes are hidden unless asked for. showSdkChanges: boolean; - // The database table open in Browse, page by page. - table?: StorageTableView; - // The file open in Files. - file?: StorageFileView; + detail?: StorageDetail; watchingSince?: number; busy: StorageAction | null; error: string; @@ -141,7 +141,7 @@ export class StoragePanel { } this.revision++; this.appsFor = ""; - this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: [], table: undefined, file: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); + this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: [], detail: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); if (this.state.open) void this.loadApps(); } @@ -162,44 +162,43 @@ export class StoragePanel { setShowSdkChanges(showSdkChanges: boolean) { this.update({ showSdkChanges }); } - async openTable(storeId: string, name: string, offset = 0) { - const bundleId = this.state.bundleId; - if (!this.ready() || !bundleId) return; - const current = this.current(); - const previous = this.state.table; - const isOpen = (table?: StorageTableView) => table?.storeId === storeId && table.name === name; + openTable(storeId: string, name: string, offset = 0) { + const previous = this.state.detail; // Paging keeps the current rows on screen until the next page arrives. - const table: StorageTableView = { storeId, name, data: isOpen(previous) ? previous?.data : undefined, loading: true, error: "" }; - this.update({ table }); - const stillOpen = () => current() && isOpen(this.state.table); - try { - const data = storageTableSchema.parse(await measureUiOperation("ui.storage.table", "ui.storage.failure", - () => this.call("mobile_app_storage_table", { bundleId, store: storeId, table: name, offset, limit: storageLimits.tableRows }))); - if (stillOpen()) this.update({ table: { ...table, data, loading: false } }); - } catch (error) { - if (stillOpen()) this.update({ table: { ...table, loading: false, error: errorMessage(error) } }); - } + const shown = previous?.kind === "table" && previous.storeId === storeId && previous.name === name ? previous.data : undefined; + const detail: StorageTableView = { kind: "table", storeId, name, data: shown, loading: true, error: "" }; + return this.load(detail, "ui.storage.table", async bundleId => { + const data = storageTableSchema.parse(await this.call("mobile_app_storage_table", { bundleId, store: storeId, table: name, offset, limit: storageLimits.tableRows })); + return { ...detail, data, loading: false }; + }); } - closeTable() { if (this.state.table) this.update({ table: undefined }); } + openFile(path: string) { + const detail: StorageFileView = { kind: "file", path, loading: true, error: "" }; + return this.load(detail, "ui.storage.file", async bundleId => { + const data = storageFileSchema.parse(await this.call("mobile_app_storage_file", { bundleId, path })); + return { ...detail, data, loading: false }; + }); + } - async openFile(path: string) { + closeDetail() { if (this.state.detail) this.update({ detail: undefined }); } + + // Opens a detail and fills in its data. A newer detail, including the next page of the same table, replaces this + // one, so a slower earlier response is dropped. + private async load(detail: StorageDetail, metric: string, fetch: (bundleId: string) => Promise) { const bundleId = this.state.bundleId; if (!this.ready() || !bundleId) return; const current = this.current(); - const file: StorageFileView = { path, loading: true, error: "" }; - this.update({ file }); - const stillOpen = () => current() && this.state.file?.path === path; + this.update({ detail }); + const open = () => current() && this.state.detail === detail; try { - const data = storageFileSchema.parse(await measureUiOperation("ui.storage.file", "ui.storage.failure", () => this.call("mobile_app_storage_file", { bundleId, path }))); - if (stillOpen()) this.update({ file: { ...file, data, loading: false } }); + const loaded = await measureUiOperation(metric, "ui.storage.failure", () => fetch(bundleId)); + if (open()) this.update({ detail: loaded }); } catch (error) { - if (stillOpen()) this.update({ file: { ...file, loading: false, error: errorMessage(error) } }); + if (open()) this.update({ detail: { ...detail, loading: false, error: errorMessage(error) } }); } } - closeFile() { if (this.state.file) this.update({ file: undefined }); } - clearChanges() { this.update({ changes: [], watchingSince: this.state.snapshot?.readAt }); } // Only the visible Changes tab re-reads storage; Browse refreshes on request. @@ -252,7 +251,7 @@ export class StoragePanel { if (bundleId !== this.state.bundleId) { this.revision++; this.selections.set(`${this.state.platform}:${this.state.deviceId}`, bundleId); - this.update({ bundleId, snapshot: undefined, changes: [], table: undefined, file: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); + this.update({ bundleId, snapshot: undefined, changes: [], detail: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); } if (bundleId) void this.refresh(); } diff --git a/tests/native-storage.test.ts b/tests/native-storage.test.ts index 91a3833a..92b35da1 100644 --- a/tests/native-storage.test.ts +++ b/tests/native-storage.test.ts @@ -38,7 +38,7 @@ test("native stores are found by their files: SwiftData, Core Data with any name "Documents/profile.json", "Library/Caches/thumbnails-wal", ]), ["sqlite:default.store:2", "sqlite:Model:2", "user-defaults:group.dev.example:1"]); assert.deepEqual(kinds("android", ["files/datastore/profile.pb", "files/datastore/settings.preferences_pb", "files/other.pb"]), - ["datastore:profile:1", "datastore:settings:1"]); + ["proto-datastore:profile:1", "datastore:settings:1"]); }); test("Proto DataStore without a schema reads as numbered fields, with repeats indexed and nested messages flattened", async () => { @@ -123,3 +123,23 @@ test("SQLite files with any name list every table's rows and columns in one read ]); } finally { await rm(root, { recursive: true, force: true }); } }); + +test("a slower earlier table page never replaces the page opened after it", async () => { + const { StoragePanel } = await import("../src/ui/storage-panel.ts"); + const pending: { offset: number; resolve: (result: unknown) => void }[] = []; + const app = { callServerTool: ({ arguments: args }: { arguments: { offset: number } }) => new Promise(resolve => pending.push({ offset: args.offset, resolve })) }; + const device = { platform: "ios", udid, name: "iPhone", state: "Booted", kind: "simulator" }; + const panel = new StoragePanel(app as never, { subscribe: () => () => {}, getSnapshot: () => ({ device }) } as never); + panel.setAvailable(true); + (panel as unknown as { update(patch: object): void }).update({ bundleId: "dev.example" }); + const page = (offset: number) => ({ structuredContent: { table: "items", columns: [{ name: "id", type: "INTEGER" }], rows: [[offset + 1]], total: 300, offset } }); + const first = panel.openTable("sqlite:app.db", "items", 0); + const second = panel.openTable("sqlite:app.db", "items", 100); + pending.find(call => call.offset === 100)!.resolve(page(100)); + await second; + pending.find(call => call.offset === 0)!.resolve(page(0)); + await first; + const detail = panel.getSnapshot().detail; + assert.equal(detail?.kind === "table" ? detail.data?.offset : undefined, 100); + panel.dispose(); +});