diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d226f43..c753eda 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/README.md b/README.md index f825fad..cc8637d 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 d432500..d212078 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/examples/native-storage-android/.gitignore b/examples/native-storage-android/.gitignore new file mode 100644 index 0000000..458fab6 --- /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 0000000..2b06be5 --- /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 0000000..9e07f3f --- /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 0000000..923258c --- /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 0000000..9073f28 --- /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 0000000..0941fef --- /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 0000000..41cb922 --- /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 0000000..ace0600 --- /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 0000000..34f518e --- /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 0000000..a0af07b --- /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 0000000..61285a6 Binary files /dev/null and b/examples/native-storage-android/gradle/wrapper/gradle-wrapper.jar differ 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 0000000..c61a118 --- /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 0000000..adff685 --- /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 0000000..4626b90 --- /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 0000000..e144128 --- /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 0000000..4a87985 --- /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 0000000..d94f382 --- /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 0000000..a8a211f --- /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 0000000..3d9941e --- /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 0000000..cdc8254 --- /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 0000000..28bd72c --- /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 0000000..abfdfd6 --- /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 0000000..6156c6b --- /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 0000000..9e8acb5 --- /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 0000000..729c3bd --- /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 diff --git a/package-lock.json b/package-lock.json index 218b6f4..523fb78 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 58f7c55..072289f 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": { diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md index 7b1e002..2e42b33 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 3283414..4886ca8 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. +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/encoding.ts b/src/server/app-storage/encoding.ts new file mode 100644 index 0000000..083654c --- /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 8373e3a..6d36bef 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 }; @@ -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,31 @@ export function parseDataStorePreferences(buffer: Buffer): ParsedEntry[] { }); } +// ---- Proto DataStore and other schema-less protobuf: field numbers become keys, and value types are guessed. + +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 = 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); + }); +} + // ---- Classifying files in the data directory. export type StoreCandidate = { kind: StoreKind; name: string; path: string; files: string[] }; @@ -217,7 +247,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 +264,21 @@ 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: "proto-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)) { + // 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 bf229e6..9eed92f 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 new file mode 100644 index 0000000..831f9d5 --- /dev/null +++ b/src/server/app-storage/preview.ts @@ -0,0 +1,76 @@ +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 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; + 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()); +} + +// 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 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(" hexBytes); +} diff --git a/src/server/app-storage/reader.ts b/src/server/app-storage/reader.ts index 052e456..17bef0c 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,14 +17,24 @@ 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 }); return output.trim() ? JSON.parse(output) : []; } -const listTables = async (run: CommandRunner, file: string) => - (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 { @@ -38,24 +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 }; } - 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.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.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)])); + 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 })) }; }); } @@ -91,10 +100,18 @@ 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 "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": 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": @@ -167,15 +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, [count]] = await Promise.all([sqlite(run, file, `PRAGMA table_info(${name})`), sqlite(run, file, `SELECT count(*) AS total FROM ${name}`)]); - const columns = info.map(column => ({ name: String(column.name), type: String(column.type ?? "") })); + const [count] = await sqlite(run, file, `SELECT count(*) AS total FROM ${name}`); // 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 00a7d21..69f9318 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"; @@ -14,9 +15,9 @@ 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"; export type ContainerFactory = (target: Target, bundleId: string) => Promise; export function createContainers(commands: Commands = systemCommands): ContainerFactory { @@ -31,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. @@ -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 2b794fc..3422596 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", 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 fac1503..3b27b70 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; @@ -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,18 @@ 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, 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(), + format: z.enum(filePreviewFormats), + content: z.string(), + 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/shared/version.ts b/src/shared/version.ts index cf6eeae..bb9747e 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 d773569..27bedc3 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 { storageLimits, storeKindLabels, storeKinds } from "../../shared/app-storage.ts"; -import type { StorageEntry, StorageSnapshot, StorageStore } from "../../shared/app-storage.ts"; +import type { ReactNode } from "react"; +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 { 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,47 @@ 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", "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; + 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."; + } + 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 +72,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.closeDetail() }} 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`} />
{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 +119,9 @@ function SearchResults({ groups, query, title, expansion }: { groups: { store: S ; } +// 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 }) { const data = table.data; @@ -83,10 +135,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 : 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)} + ; + })} )} } {data && data.rows.length === 0 &&

This table has no rows.

} @@ -111,25 +166,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.closeDetail() }} 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 +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(); }; + 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)} @@ -188,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-entries.tsx b/src/ui/components/storage-entries.tsx index 0dc14d8..61a89dd 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/components/storage-parts.tsx b/src/ui/components/storage-parts.tsx index 807d229..c2ac0db 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-labels.ts b/src/ui/storage-labels.ts index 547fbec..31740bf 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 5a8025d..003754c 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"; @@ -24,7 +24,11 @@ export type StorageChangeEvent = { before?: StorageEntry; after?: StorageEntry; }; -export type StorageTableView = { storeId: string; name: string; data?: StorageTable; 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; @@ -41,8 +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; + detail?: StorageDetail; 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: [], detail: undefined, watchingSince: undefined, busy: null, error: "", status: "" }); if (this.state.open) void this.loadApps(); } @@ -159,27 +162,43 @@ export class StoragePanel { setShowSdkChanges(showSdkChanges: boolean) { this.update({ showSdkChanges }); } - async openTable(storeId: string, name: string, offset = 0) { + 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 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 }; + }); + } + + 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 }; + }); + } + + 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 previous = this.state.table; - const isOpen = (table?: StorageTableView) => table?.storeId === storeId && table.name === name; - // 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); + this.update({ detail }); + const open = () => current() && this.state.detail === detail; 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 } }); + const loaded = await measureUiOperation(metric, "ui.storage.failure", () => fetch(bundleId)); + if (open()) this.update({ detail: loaded }); } catch (error) { - if (stillOpen()) this.update({ table: { ...table, loading: false, error: errorMessage(error) } }); + if (open()) this.update({ detail: { ...detail, loading: false, error: errorMessage(error) } }); } } - closeTable() { if (this.state.table) this.update({ table: undefined }); } - clearChanges() { this.update({ changes: [], watchingSince: this.state.snapshot?.readAt }); } // Only the visible Changes tab re-reads storage; Browse refreshes on request. @@ -232,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, 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/app-storage.test.ts b/tests/app-storage.test.ts index 810d7dc..bc4e3d6 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 0000000..6fe480b --- /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 0000000..d572367 --- /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 0000000..1b8baa1 Binary files /dev/null and b/tests/fixtures/app-storage/native/session.archive differ diff --git a/tests/fixtures/app-storage/native/state.plist b/tests/fixtures/app-storage/native/state.plist new file mode 100644 index 0000000..4b99d9c Binary files /dev/null and b/tests/fixtures/app-storage/native/state.plist differ diff --git a/tests/native-storage.test.ts b/tests/native-storage.test.ts new file mode 100644 index 0000000..92b35da --- /dev/null +++ b/tests/native-storage.test.ts @@ -0,0 +1,145 @@ +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"]), + ["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 () => { + 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 }); } +}); + +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(); +});