From 8ca5543651596b9e808ce238093a8f2c645a84ae Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 14:43:59 +0100 Subject: [PATCH 1/7] feat: bind media HTTP server to UDS; expose via native content URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the backend's blob/icon HTTP server (Fastify, registered by @comapeo/core's MapeoManager) off a localhost TCP port and onto a third Unix domain socket alongside the existing comapeo.sock and control.sock. Wrap the socket in a native ContentProvider on Android and a global URLProtocol on iOS so React Native's can stream the bytes directly without exposing an HTTP endpoint other apps on the device can read, and without copying images to disk before they can be shared. Backend - backend/index.js accepts a fifth argv positional (mediaSocketPath) and binds Fastify to it. Fails fast if the arg is missing. - patches/@comapeo+core+7.1.0.patch makes getFastifyServerAddress() return "" when the address is a UDS path so MapeoManager#getMediaBaseUrl produces relative paths like /blobs//...; the RN bridge then translates these to platform-native URLs. patch-package added as a backend devDep and applied automatically by a new postbackend:install script. Android - New MediaContentProvider exposes the UDS-bound server as content://${applicationId}.comapeo.media/. openFile() returns the read end of a ParcelFileDescriptor pipe; a worker thread connects to media.sock, sends an HTTP/1.0 GET (HTTP/1.0 forbids chunked encoding, so the body is bytes-until-EOF — no chunk decoder needed), parses status+headers, then copies the body into the pipe. - AndroidManifest.xml declares the provider exported=false and grantUriPermissions=true (the latter for a future share-sheet flow). - ComapeoCoreModule exposes getMediaContentAuthority() so the JS bridge can build URLs from the host app's applicationId. - NodeJSService passes media.sock as a fifth argv positional and deletes it on cleanup alongside the other two sockets. iOS - New MediaURLProtocol intercepts comapeo://media/ URL loads, connects the same UDS, sends HTTP/1.0 GET, parses headers, and streams the body via urlProtocol(_:didLoad:) chunks for bounded memory on large images. Reuses the existing connectWithRetry/connectSocket helpers. - AppLifecycleDelegate registers the protocol class once via a static-let side-effect, force-evaluated in applicationDidBecomeActive so it fires before the first image render. - NodeJSService stores mediaSocketPath, includes it in the sockaddr_un.sun_path 104-byte precondition, and threads it through argv. ComapeoCoreModule mirrors the Android getMediaContentAuthority Function (returns "" — iOS uses a fixed scheme). JS bridge - src/mediaUrl.ts exports toNativeMediaUrl(relativePath) which returns content:// on Android and comapeo://media on iOS. Authority cached after first lookup. Out of scope (per plan): share-sheet integration, maps URL handling on iOS (the maps plugin is still stubbed there), upstream PR to @comapeo/core. Resolves the "blobs/icons over UDS" follow-up tracked in agents.md (#31). Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/main/AndroidManifest.xml | 17 + .../com/comapeo/core/ComapeoCoreModule.kt | 11 + .../com/comapeo/core/ComapeoCoreService.kt | 1 + .../java/com/comapeo/core/NodeJSService.kt | 3 + .../core/media/MediaContentProvider.kt | 226 +++++ backend/index.js | 37 +- backend/package-lock.json | 892 +++++++++--------- backend/package.json | 1 + backend/patches/@comapeo+core+7.1.0.patch | 18 + ios/AppLifecycleDelegate.swift | 24 + ios/ComapeoCoreModule.swift | 8 + ios/MediaURLProtocol.swift | 341 +++++++ ios/NodeJSService.swift | 16 +- package.json | 1 + src/index.ts | 1 + src/mediaUrl.ts | 57 ++ 16 files changed, 1175 insertions(+), 479 deletions(-) create mode 100644 android/src/main/java/com/comapeo/core/media/MediaContentProvider.kt create mode 100644 backend/patches/@comapeo+core+7.1.0.patch create mode 100644 ios/MediaURLProtocol.swift create mode 100644 src/mediaUrl.ts diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 6e4d691..b639369 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -28,5 +28,22 @@ android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync"/> + + + diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt index eca5388..cc13e29 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt @@ -1,5 +1,6 @@ package com.comapeo.core +import com.comapeo.core.media.MediaContentProvider import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import java.io.File @@ -40,5 +41,15 @@ class ComapeoCoreModule : Module() { Function("postMessage") { message: String -> ipc.sendMessage(message) } + + // The authority of the MediaContentProvider for the consuming app. + // Read once on the JS side at startup so blob/icon paths returned by + // the backend can be rewritten to `content:///...` URIs + // before they reach React Native's . + Function("getMediaContentAuthority") { + val ctx = appContext.reactContext + ?: throw IllegalStateException("React context not available") + MediaContentProvider.authorityFor(ctx) + } } } diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt index 74d0b22..878f1c2 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt @@ -39,6 +39,7 @@ class ComapeoCoreService : Service() { const val NOTIFICATION_ID = 1 const val COMAPEO_SOCKET_FILENAME = "comapeo.sock" const val CONTROL_SOCKET_FILENAME = "control.sock" + const val MEDIA_SOCKET_FILENAME = "media.sock" /** * Tracks the number of active service instances in this process. * Used to prevent Process.killProcess() in onDestroy from killing a diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index 41f52e9..85390d6 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -39,6 +39,7 @@ class NodeJSService(context: android.content.Context) : ContextWrapper(context) private val jsFile: File = File(nodeProjectDir, NODEJS_PROJECT_INDEX_FILENAME) private val comapeoSocketFile: File = File(filesDir, ComapeoCoreService.COMAPEO_SOCKET_FILENAME) private val controlSocketFile: File = File(filesDir, ComapeoCoreService.CONTROL_SOCKET_FILENAME) + private val mediaSocketFile: File = File(filesDir, ComapeoCoreService.MEDIA_SOCKET_FILENAME) private val sharedPrefsName = packageName + SHARED_PREFS_NAME_POSTFIX private val json = Json { encodeDefaults = true } private val ipcDeferred = CompletableDeferred() @@ -92,6 +93,7 @@ class NodeJSService(context: android.content.Context) : ContextWrapper(context) comapeoSocketFile.absolutePath, controlSocketFile.absolutePath, dataDir, + mediaSocketFile.absolutePath, ) ) log("NodeJS service completed with exit code $exitCode") @@ -139,6 +141,7 @@ class NodeJSService(context: android.content.Context) : ContextWrapper(context) private fun deleteSocketFiles() { comapeoSocketFile.delete() controlSocketFile.delete() + mediaSocketFile.delete() } private fun shouldCopyAssets(): Boolean { diff --git a/android/src/main/java/com/comapeo/core/media/MediaContentProvider.kt b/android/src/main/java/com/comapeo/core/media/MediaContentProvider.kt new file mode 100644 index 0000000..c404e66 --- /dev/null +++ b/android/src/main/java/com/comapeo/core/media/MediaContentProvider.kt @@ -0,0 +1,226 @@ +package com.comapeo.core.media + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.webkit.MimeTypeMap +import com.comapeo.core.ComapeoCoreService +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executors + +/** + * Exposes the backend's blob/icon HTTP server (bound to a Unix domain socket + * inside the app sandbox) as `content://` URIs. + * + * The backend Fastify server listens on `media.sock` in the same `filesDir` + * the dual-process foreground service writes to. This provider runs in the + * main app process; both processes share `filesDir`, so the path is + * deterministic on both sides. + * + * `openFile()` returns the read-end of a pipe whose write-end is fed by an + * HTTP/1.0 request issued over the UDS on a worker thread. HTTP/1.0 is + * deliberate: it forbids `Transfer-Encoding: chunked`, so the response body + * is delimited by EOF (i.e. the server closes the connection after the + * payload). That keeps the parser tiny — we read until the blank line ends + * the headers, then `copyTo` the rest. No chunk-decoding state machine. + * + * The provider is declared as `exported="false"` (only this app can target + * it) and `grantUriPermissions="true"` so a future share-sheet path can hand + * a one-shot read grant to another app via `Intent.FLAG_GRANT_READ_URI_PERMISSION`. + */ +class MediaContentProvider : ContentProvider() { + companion object { + const val AUTHORITY_SUFFIX = ".comapeo.media" + + /** Returns this provider's authority for the given app `Context`. */ + fun authorityFor(context: Context): String = + context.packageName + AUTHORITY_SUFFIX + + private const val CONNECT_RETRIES = 5 + private const val CONNECT_INITIAL_DELAY_MS = 100L + private const val CONNECT_MAX_DELAY_MS = 5000L + private const val PIPE_COPY_BUFFER = 64 * 1024 + } + + /** + * One thread per concurrent media stream. Each request blocks reading + * the HTTP response and writing the pipe's far end, so a coroutine + * dispatcher would just turn into one-OS-thread-per-request anyway. + * `cachedThreadPool` reuses idle threads and reaps them after 60 s, which + * matches the bursty access pattern of an image-heavy list view. + */ + private val executor = Executors.newCachedThreadPool { r -> + Thread(r, "comapeo-media-stream").apply { isDaemon = true } + } + + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri): String? { + val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + if (ext.isNullOrEmpty()) return "application/octet-stream" + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) + ?: "application/octet-stream" + } + + @Throws(FileNotFoundException::class) + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + if (mode != "r") { + throw FileNotFoundException("Only mode 'r' is supported (got '$mode')") + } + + val ctx = context + ?: throw FileNotFoundException("Provider has no context") + val socketFile = File(ctx.filesDir, ComapeoCoreService.MEDIA_SOCKET_FILENAME) + + val pathAndQuery = buildString { + append(uri.encodedPath ?: "/") + uri.encodedQuery?.let { append("?").append(it) } + } + + val pipe = ParcelFileDescriptor.createReliablePipe() + val readSide = pipe[0] + val writeSide = pipe[1] + + executor.execute { + try { + streamHttpResponse(socketFile, pathAndQuery, writeSide) + } catch (e: Exception) { + // Surface the failure to the consumer instead of silently + // truncating the stream — Glide / Fresco will treat a closed + // pipe with no error as an "incomplete file" warning, which + // is harder to diagnose than an explicit error message. + try { + writeSide.closeWithError(e.message ?: "media stream error") + } catch (_: IOException) { + // Read side already closed — nothing to report to. + } + } + } + + return readSide + } + + private fun streamHttpResponse( + socketFile: File, + pathAndQuery: String, + writeSide: ParcelFileDescriptor, + ) { + ParcelFileDescriptor.AutoCloseOutputStream(writeSide).use { out -> + connectWithRetry(socketFile).use { localSocket -> + val request = buildString { + // HTTP/1.0 — forbids Transfer-Encoding: chunked, so the + // response body is just bytes-until-EOF. See class doc. + append("GET ").append(pathAndQuery).append(" HTTP/1.0\r\n") + append("Host: localhost\r\n") + append("Connection: close\r\n") + append("\r\n") + } + localSocket.outputStream.write( + request.toByteArray(StandardCharsets.US_ASCII) + ) + localSocket.outputStream.flush() + + val input = localSocket.inputStream + val status = readStatusAndDiscardHeaders(input) + if (status !in 200..299) { + throw FileNotFoundException("HTTP $status for $pathAndQuery") + } + + input.copyTo(out, PIPE_COPY_BUFFER) + } + } + } + + /** + * Reads the status line and headers, leaving `input` positioned at the + * first body byte. Returns the numeric status code. + */ + private fun readStatusAndDiscardHeaders(input: InputStream): Int { + val statusLine = readLine(input) + ?: throw IOException("No status line from media socket") + // "HTTP/1.0 200 OK" + val parts = statusLine.split(' ', limit = 3) + if (parts.size < 2) throw IOException("Malformed status line: $statusLine") + val status = parts[1].toIntOrNull() + ?: throw IOException("Malformed status code: $statusLine") + while (true) { + val line = readLine(input) ?: throw IOException("Unexpected EOF in headers") + if (line.isEmpty()) break + } + return status + } + + /** Reads up to and including CRLF; returns the line without the CRLF, or null at EOF. */ + private fun readLine(input: InputStream): String? { + val buf = ByteArrayOutputStream() + var prev = -1 + while (true) { + val b = input.read() + if (b == -1) { + return if (buf.size() == 0) null + else buf.toString(StandardCharsets.ISO_8859_1.name()) + } + if (prev == '\r'.code && b == '\n'.code) { + val raw = buf.toByteArray() + return String(raw, 0, raw.size - 1, StandardCharsets.ISO_8859_1) + } + buf.write(b) + prev = b + } + } + + private fun connectWithRetry(socketFile: File): LocalSocket { + val addr = LocalSocketAddress( + socketFile.absolutePath, + LocalSocketAddress.Namespace.FILESYSTEM + ) + var lastError: IOException? = null + var delayMs = CONNECT_INITIAL_DELAY_MS + repeat(CONNECT_RETRIES) { + try { + return LocalSocket().apply { connect(addr) } + } catch (e: IOException) { + lastError = e + try { Thread.sleep(delayMs) } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("Interrupted while connecting", e) + } + delayMs = (delayMs * 2).coerceAtMost(CONNECT_MAX_DELAY_MS) + } + } + throw IOException( + "Could not connect to media socket: ${socketFile.absolutePath}", + lastError + ) + } + + // Read-only provider. ContentResolver only reaches the methods below + // when callers issue query/insert/etc., which never happens for image + // loads — those go through openFile / openInputStream. + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, s: String?, sa: Array?): Int = 0 + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/backend/index.js b/backend/index.js index 0ca94ed..1f46368 100644 --- a/backend/index.js +++ b/backend/index.js @@ -16,8 +16,20 @@ const MIGRATIONS_FOLDER_PATH = fileURLToPath( console.log("Starting Comapeo Node server..."); -const [comapeoSocketPath, controlSocketPath, privateStorageDir] = - process.argv.slice(2); +const [ + comapeoSocketPath, + controlSocketPath, + privateStorageDir, + mediaSocketPath, +] = process.argv.slice(2); + +if (!mediaSocketPath) { + console.error( + "Missing media socket path argv. The native NodeJSService must pass " + + "[node, indexPath, comapeoSocketPath, controlSocketPath, privateStorageDir, mediaSocketPath].", + ); + process.exit(1); +} const fastify = Fastify(); @@ -39,23 +51,32 @@ const controlIpcServer = new SimpleRpcServer({ }, }); -// Listen on both sockets in parallel, then drive the readiness state machine. -// `started` fires as soon as both `listen()` promises resolve so a control -// client knows the comapeo socket is accepting connections; `ready` fires -// after a 1 s settle window for callers that want a stronger "I won't see -// startup races" signal. Late-connecting clients receive both replayed. +// Listen on all three sockets in parallel, then drive the readiness state +// machine. `started` fires as soon as the listen() promises resolve so a +// control client knows the sockets are accepting connections; `ready` +// fires after a 1 s settle window for callers that want a stronger +// "I won't see startup races" signal. Late-connecting clients receive both +// replayed. // // See SimpleRpcServer for why the settle window exists. Native control-IPC // clients (Swift, Kotlin) poll for the socket file plus retry, which can // land their first successful accept several tens of ms after the // broadcast — without the replay they would see nothing. +// +// The media socket carries blob/icon HTTP responses streamed by the Fastify +// plugins registered in `@comapeo/core`. Binding to a UDS instead of a TCP +// port keeps the bytes inside the app sandbox: only the native module can +// connect (Android via LocalSocket from the MediaContentProvider, iOS via +// AF_UNIX from MediaURLProtocol), so no other app on the device can read +// the URLs. Promise.all([ controlIpcServer.listen(controlSocketPath), comapeoRpcServer.listen(comapeoSocketPath), + fastify.listen({ path: mediaSocketPath }), ]) .then(async () => { console.log( - `Node server listening on ${controlSocketPath} and ${comapeoSocketPath}`, + `Node server listening on ${controlSocketPath}, ${comapeoSocketPath}, ${mediaSocketPath}`, ); controlIpcServer.setReadinessPhase("started"); await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/backend/package-lock.json b/backend/package-lock.json index 94b5b68..40cbbf9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,6 +30,7 @@ "@types/streamx": "2.9.5", "@types/tar-stream": "3.1.4", "magic-string": "0.30.21", + "patch-package": "8.0.0", "rollup": "4.60.2", "rollup-plugin-esbuild": "6.2.1", "tar-stream": "3.1.8", @@ -417,474 +418,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -2550,6 +2083,13 @@ "@types/readable-stream": "*" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2751,6 +2291,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3128,6 +2678,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browser-or-node": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", @@ -3265,6 +2828,22 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3445,6 +3024,13 @@ "node": ">= 14" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -4381,6 +3967,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/filter-obj": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-6.1.0.tgz", @@ -4407,6 +4006,16 @@ "node": ">=14" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-tree": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/flat-tree/-/flat-tree-1.13.0.tgz", @@ -4454,6 +4063,22 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs-native-extensions": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.5.0.tgz", @@ -4465,6 +4090,13 @@ "which-runtime": "^1.2.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4612,6 +4244,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -4819,6 +4461,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4975,6 +4629,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-options": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-options/-/is-options-1.0.2.tgz", @@ -5134,6 +4798,19 @@ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", @@ -5143,6 +4820,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/ky": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", @@ -5307,6 +4994,33 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", @@ -5856,6 +5570,126 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6427,6 +6261,66 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -6856,6 +6750,16 @@ "node": ">=10" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/smp-noto-glyphs": { "version": "1.0.0-pre.0", "resolved": "https://registry.npmjs.org/smp-noto-glyphs/-/smp-noto-glyphs-1.0.0-pre.0.tgz", @@ -7270,6 +7174,19 @@ "codecs": "^3.1.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -7458,6 +7375,19 @@ "node": ">=0.6.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -7571,6 +7501,16 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unix-path-resolve": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unix-path-resolve/-/unix-path-resolve-1.0.2.tgz", @@ -7808,6 +7748,22 @@ "url": "https://opencollective.com/xstate" } }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 7aec498..1dbbf3d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "@types/streamx": "2.9.5", "@types/tar-stream": "3.1.4", "magic-string": "0.30.21", + "patch-package": "8.0.0", "rollup": "4.60.2", "rollup-plugin-esbuild": "6.2.1", "tar-stream": "3.1.8", diff --git a/backend/patches/@comapeo+core+7.1.0.patch b/backend/patches/@comapeo+core+7.1.0.patch new file mode 100644 index 0000000..f56aa9c --- /dev/null +++ b/backend/patches/@comapeo+core+7.1.0.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/@comapeo/core/src/fastify-plugins/utils.js b/node_modules/@comapeo/core/src/fastify-plugins/utils.js +index c62cd8b..403174c 100644 +--- a/node_modules/@comapeo/core/src/fastify-plugins/utils.js ++++ b/node_modules/@comapeo/core/src/fastify-plugins/utils.js +@@ -29,7 +29,12 @@ export async function getFastifyServerAddress(server, { timeout } = {}) { + } + + if (typeof address === 'string') { +- return address ++ // UDS-bound: server.address() returns the socket path, not a host:port. ++ // Return an empty base so MapeoManager#getMediaBaseUrl produces relative ++ // paths like '/blobs/'. The @comapeo/core-react-native ++ // module rewrites these to platform-native URLs (content:// on Android, ++ // comapeo:// on iOS) before they reach React Native. ++ return '' + } + + // Full address construction for non unix-socket address diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index 2f35f57..fe5793a 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -16,7 +16,25 @@ import UIKit /// callbacks, but every instance routes through that single static — so /// `NodeMobileStartNode`'s once-per-process constraint is preserved no matter /// how many delegate instances exist. +/// +/// On first instantiation by Expo's autolinking, this class also wires +/// `MediaURLProtocol` into the global URL-loading system so `comapeo://media/...` +/// URLs (the platform-native form of `BlobApi.getUrl()` / `IconApi.getIconUrl()` +/// results, see `src/mediaUrl.ts`) stream straight from the backend's +/// UDS-bound HTTP server into React Native image components. public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { + /// Idempotent installer for the global URL protocol — `static let` + /// guarantees at-most-one registration regardless of how many + /// `AppLifecycleDelegate` instances Expo creates. Side-effect happens + /// the first time anything reads this property; the value itself is + /// only there to make the body run. + private static let _mediaUrlProtocolInstalled: Bool = { + MediaURLProtocol.mediaSocketPathProvider = { + AppLifecycleDelegate.nodeService.mediaSocketPath + } + URLProtocol.registerClass(MediaURLProtocol.self) + return true + }() /// Process-wide `NodeJSService`. Use this from any thread. /// /// Going through a delegate instance (e.g. via the DEBUG-only `shared` @@ -163,6 +181,12 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { public func applicationDidBecomeActive(_ application: UIApplication) { log("applicationDidBecomeActive") + // Force-evaluate the lazy `static let` so URLProtocol.registerClass + // runs exactly once for the lifetime of the process. Reading the + // value here (even unused) is the simplest way to drive Swift's + // dispatch_once-backed static init; we deliberately don't gate on + // a Bool because the language guarantees at-most-once already. + _ = Self._mediaUrlProtocolInstalled // Start is guarded by `state == .stopped`, so subsequent foreground // transitions in the same process are no-ops. Self.nodeService.start() diff --git a/ios/ComapeoCoreModule.swift b/ios/ComapeoCoreModule.swift index 1aefe6a..e15c0f1 100644 --- a/ios/ComapeoCoreModule.swift +++ b/ios/ComapeoCoreModule.swift @@ -68,5 +68,13 @@ public class ComapeoCoreModule: Module { ipc: self.ipc ) } + + // Mirror of the Android Function so the JS bridge in `src/mediaUrl.ts` + // can call the same name on both platforms. Returns the empty string + // on iOS — there is no per-app authority because iOS uses a fixed + // `comapeo://media/...` scheme registered by `MediaURLProtocol`. + Function("getMediaContentAuthority") { () -> String in + return "" + } } } diff --git a/ios/MediaURLProtocol.swift b/ios/MediaURLProtocol.swift new file mode 100644 index 0000000..2291fff --- /dev/null +++ b/ios/MediaURLProtocol.swift @@ -0,0 +1,341 @@ +import Foundation + +/// Bridges `comapeo://media/...` URL loads onto the backend's UDS-bound HTTP +/// server, streaming the response body straight into the URL loader without +/// buffering the whole image in memory. +/// +/// Registered globally via `URLProtocol.registerClass(_:)` at app launch +/// (see `AppLifecycleDelegate.applicationDidFinishLaunching`). Any +/// `URLSession.shared` request — including the one React Native's image +/// loader uses for non-`http` schemes — picks this up and routes it here. +/// +/// **HTTP/1.0 by design.** The request line uses `HTTP/1.0` so Fastify's +/// response is forced into "Connection: close, body delimited by EOF" mode. +/// That sidesteps `Transfer-Encoding: chunked` and `Content-Length`-based +/// framing, leaving only headers + raw body to parse — a much smaller +/// surface than implementing chunked decoding inside this protocol. +/// +/// **Streaming.** Once the headers are consumed, the protocol enters a +/// read-and-forward loop on a background queue, calling +/// `urlProtocol(_:didLoad:)` for each chunk so the URL loader / image +/// decoder can begin decoding before the full body has arrived. That keeps +/// memory bounded for large images regardless of source-side framing. +/// +/// **Cancellation.** `stopLoading()` flips `isCancelled` and shuts the +/// socket down; the loop notices on the next read and exits without +/// emitting a completion or error to the now-dead client. +class MediaURLProtocol: URLProtocol { + /// Source for the path the backend's Fastify HTTP server is bound to. + /// `AppLifecycleDelegate` installs the closure pointing at + /// `NodeJSService.mediaSocketPath` once the service is constructed. + /// A nil value means we haven't booted yet — clients see a clear error + /// instead of a hang. + static var mediaSocketPathProvider: (() -> String?)? + + static let scheme = "comapeo" + static let host = "media" + + /// Connection-attempt budget. Image loads happen most often as part of a + /// list scroll; if the backend isn't up after ~10 s of retry, hanging + /// the request further would only make the UI worse. + private static let connectMaxRetries = 5 + + private let workQueue = DispatchQueue(label: "com.comapeo.core.media.url-protocol") + private var fd: Int32 = -1 + private let stateLock = NSLock() + private var isCancelled = false + + override class func canInit(with request: URLRequest) -> Bool { + guard let url = request.url else { return false } + return url.scheme?.lowercased() == scheme && url.host?.lowercased() == host + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + workQueue.async { [weak self] in + self?.performRequest() + } + } + + override func stopLoading() { + stateLock.lock() + isCancelled = true + let fdSnapshot = fd + stateLock.unlock() + if fdSnapshot >= 0 { + // Wake any blocked read in performRequest so the loop exits. + _ = Darwin.shutdown(fdSnapshot, SHUT_RDWR) + } + } + + private func performRequest() { + guard let url = request.url else { + failClient(.init(.badURL)) + return + } + + guard let socketPath = MediaURLProtocol.mediaSocketPathProvider?() else { + failClient(.init(.cannotConnectToHost, + userInfo: [NSLocalizedDescriptionKey: "Media socket path not configured"])) + return + } + + let pathAndQuery = (url.path.isEmpty ? "/" : url.path) + + (url.query.map { "?\($0)" } ?? "") + + let connectedFd: Int32 + do { + connectedFd = try connectWithRetry( + socketPath: socketPath, + maxRetries: MediaURLProtocol.connectMaxRetries + ) + } catch { + failClient(.init(.cannotConnectToHost, + userInfo: [NSUnderlyingErrorKey: error, + NSLocalizedDescriptionKey: error.localizedDescription])) + return + } + + stateLock.lock() + // Honour stopLoading() that arrived during connectWithRetry. + guard !isCancelled else { + stateLock.unlock() + close(connectedFd) + return + } + fd = connectedFd + stateLock.unlock() + + defer { + stateLock.lock() + let toClose = fd + fd = -1 + stateLock.unlock() + if toClose >= 0 { close(toClose) } + } + + let httpRequest = + "GET \(pathAndQuery) HTTP/1.0\r\n" + + "Host: localhost\r\n" + + "Connection: close\r\n" + + "\r\n" + guard let requestBytes = httpRequest.data(using: .ascii) else { + failClient(.init(.badURL)) + return + } + + if !writeAll(connectedFd, data: requestBytes) { + if !cancelled() { + failClient(.init(.networkConnectionLost)) + } + return + } + + let headerResult: HeaderParseResult + do { + headerResult = try readHeaders(fd: connectedFd) + } catch { + if !cancelled() { + failClient(.init(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription])) + } + return + } + + guard (200..<300).contains(headerResult.status) else { + failClient(.init(.fileDoesNotExist, + userInfo: [NSLocalizedDescriptionKey: + "HTTP \(headerResult.status) for \(pathAndQuery)"])) + return + } + + // Build a fake URLResponse — image loaders only look at MIME type + // and (when present) expectedContentLength, so we don't need to + // round-trip every header. + let mimeType = headerResult.headers["content-type"] + ?? mimeFromExtension(url.pathExtension) + ?? "application/octet-stream" + let length = headerResult.headers["content-length"].flatMap(Int.init) ?? -1 + + let response = URLResponse( + url: url, + mimeType: mimeType, + expectedContentLength: length, + textEncodingName: nil + ) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + // Flush any over-read body bytes from the header-parsing buffer first, + // then drain the socket to EOF. + if !headerResult.bodyTail.isEmpty { + client?.urlProtocol(self, didLoad: headerResult.bodyTail) + } + + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while !cancelled() { + let n = Darwin.read(connectedFd, &buffer, buffer.count) + if n > 0 { + client?.urlProtocol(self, didLoad: Data(buffer[0.. Bool { + stateLock.lock(); defer { stateLock.unlock() } + return isCancelled + } + + private func failClient(_ error: URLError) { + client?.urlProtocol(self, didFailWithError: error) + } + + private func writeAll(_ fd: Int32, data: Data) -> Bool { + return data.withUnsafeBytes { rawBuf -> Bool in + guard let base = rawBuf.baseAddress else { return false } + var written = 0 + while written < data.count { + let n = Darwin.write(fd, base.advanced(by: written), data.count - written) + if n > 0 { written += n; continue } + if n < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { + var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + _ = Darwin.poll(&pfd, 1, 5000) + continue + } + return false + } + return false + } + return true + } + } + + private struct HeaderParseResult { + let status: Int + let headers: [String: String] + /// Body bytes that were over-read while looking for the end-of-headers + /// marker. Must be flushed to the URL loader before draining the socket. + let bodyTail: Data + } + + /// Reads bytes from `fd` until the end of the HTTP header section + /// (CRLF CRLF), then parses status + headers. Any body bytes that + /// landed in the same read are returned as `bodyTail`. + private func readHeaders(fd: Int32) throws -> HeaderParseResult { + var buf = Data() + let chunk = 4096 + var scratch = [UInt8](repeating: 0, count: chunk) + let terminator: [UInt8] = [0x0d, 0x0a, 0x0d, 0x0a] // \r\n\r\n + let maxHeaderBytes = 64 * 1024 + + while true { + // Bound-check: a runaway server should not be able to make us + // allocate megabytes of header. + if buf.count > maxHeaderBytes { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: "Header section too large"]) + } + let n = Darwin.read(fd, &scratch, chunk) + if n > 0 { + buf.append(scratch, count: n) + if let endIndex = findTerminator(in: buf, terminator: terminator) { + let headerData = buf[.. Int? { + guard data.count >= terminator.count else { return nil } + return data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Int? in + let bytes = ptr.bindMemory(to: UInt8.self) + outer: for i in 0...(data.count - terminator.count) { + for j in 0.. (Int, [String: String]) { + guard let raw = String(data: headerData, encoding: .isoLatin1) else { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: "Header bytes not parseable"]) + } + // Split on CRLF; tolerate bare LF for safety. + let lines = raw.split(whereSeparator: { $0 == "\r" || $0 == "\n" }) + .map { String($0) } + .filter { !$0.isEmpty } + guard let statusLine = lines.first else { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: "Empty header section"]) + } + let parts = statusLine.split(separator: " ", maxSplits: 2) + guard parts.count >= 2, let status = Int(parts[1]) else { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: "Malformed status line: \(statusLine)"]) + } + var headers: [String: String] = [:] + for line in lines.dropFirst() { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[.. String? { + switch ext.lowercased() { + case "jpg", "jpeg": return "image/jpeg" + case "png": return "image/png" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "svg": return "image/svg+xml" + case "heic": return "image/heic" + default: return nil + } + } +} diff --git a/ios/NodeJSService.swift b/ios/NodeJSService.swift index 918955a..76902a0 100644 --- a/ios/NodeJSService.swift +++ b/ios/NodeJSService.swift @@ -21,6 +21,7 @@ class NodeJSService { static let comapeoSocketFilename = "comapeo.sock" static let controlSocketFilename = "control.sock" + static let mediaSocketFilename = "media.sock" private let socketDir: String /// Backend's `privateStorageDir` argv positional. Mirrors Android's @@ -31,6 +32,10 @@ class NodeJSService { private let privateStorageDir: String let comapeoSocketPath: String let controlSocketPath: String + /// Unix-domain socket the backend's Fastify HTTP server binds to, + /// serving streamed blob/icon bytes. Read by `MediaURLProtocol` on + /// the React Native side to satisfy `comapeo://media/...` URL loads. + let mediaSocketPath: String private var controlIPC: NodeJSIPC? private var nodeThread: Thread? private let lock = NSLock() @@ -75,13 +80,14 @@ class NodeJSService { self.privateStorageDir = privateStorageDir self.comapeoSocketPath = (socketDir as NSString).appendingPathComponent(NodeJSService.comapeoSocketFilename) self.controlSocketPath = (socketDir as NSString).appendingPathComponent(NodeJSService.controlSocketFilename) + self.mediaSocketPath = (socketDir as NSString).appendingPathComponent(NodeJSService.mediaSocketFilename) - // Fail loudly if either socket path won't fit in sockaddr_un.sun_path + // Fail loudly if any socket path won't fit in sockaddr_un.sun_path // (104 bytes on Darwin, including the null terminator). A silently // truncated path causes bind() to succeed against a different file — // surfacing later as a mysterious connection-refused or hang. let sunPathMax = 104 - for path in [comapeoSocketPath, controlSocketPath] { + for path in [comapeoSocketPath, controlSocketPath, mediaSocketPath] { let needed = path.utf8.count + 1 precondition( needed <= sunPathMax, @@ -197,9 +203,11 @@ class NodeJSService { transitionState(to: .started) // argv shape matches Android's NodeJSService.kt: - // [node, indexPath, comapeoSocketPath, controlSocketPath, privateStorageDir] + // [node, indexPath, comapeoSocketPath, controlSocketPath, privateStorageDir, mediaSocketPath] // The third positional is consumed by backend/index.js as // `privateStorageDir` and handed to createComapeo({privateStorageDir,...}). + // The fourth positional is the path of the Unix-domain socket the + // bundled Fastify HTTP server (blobs + icons) binds to. // // `--no-experimental-fetch` disables Node's built-in `globalThis.fetch` // (and thus the lazy-loaded undici under it). nodejs-mobile iOS runs @@ -218,6 +226,7 @@ class NodeJSService { comapeoSocketPath, controlSocketPath, privateStorageDir, + mediaSocketPath, ] let exitCode = nodeEntryPoint(args) log("Node.js exited with code \(exitCode)") @@ -254,6 +263,7 @@ class NodeJSService { let fm = FileManager.default try? fm.removeItem(atPath: comapeoSocketPath) try? fm.removeItem(atPath: controlSocketPath) + try? fm.removeItem(atPath: mediaSocketPath) } } diff --git a/package.json b/package.json index 5faec58..fcbd115 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "open:android": "open -a \"Android Studio\" example/android", "download:nodejs-mobile": "./scripts/download-nodejs-mobile.sh", "backend:install": "npm ci --ignore-scripts --prefix backend", + "postbackend:install": "cd backend && npx --no-install patch-package", "prebackend:build": "npm run backend:install", "backend:build": "node ./scripts/build-backend.ts" }, diff --git a/src/index.ts b/src/index.ts index aa348d7..e8733fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ // Reexport the native module. On web, it will be resolved to ComapeoCoreModule.web.ts // and on native platforms to ComapeoCoreModule.ts export { comapeo } from "./ComapeoCoreModule"; +export { toNativeMediaUrl } from "./mediaUrl"; export * from "./ComapeoCore.types"; diff --git a/src/mediaUrl.ts b/src/mediaUrl.ts new file mode 100644 index 0000000..f3f25cb --- /dev/null +++ b/src/mediaUrl.ts @@ -0,0 +1,57 @@ +import { Platform } from "react-native"; +import { requireNativeModule } from "expo"; + +interface MediaUrlNativeModule { + getMediaContentAuthority(): string; +} + +const nativeModule = requireNativeModule("ComapeoCore"); + +/** + * Lazily-resolved authority of the Android `MediaContentProvider`. iOS + * returns "" from the same Function — the iOS scheme is fixed + * (`comapeo://media/...`) and does not depend on the consuming app's + * applicationId. Cached on first read so we don't hop into native code on + * every render. + */ +let cachedAndroidAuthority: string | null = null; + +function androidAuthority(): string { + if (cachedAndroidAuthority === null) { + cachedAndroidAuthority = nativeModule.getMediaContentAuthority(); + } + return cachedAndroidAuthority; +} + +/** + * Translates a relative media path returned by the backend (e.g. the result + * of `BlobApi.getUrl()` / `IconApi.getIconUrl()`, post-`@comapeo/core` patch) + * into a platform-native URL that React Native's `` can fetch + * directly without exposing an HTTP endpoint to other apps on the device. + * + * Android → `content://.comapeo.media/`, served by + * `MediaContentProvider` which streams bytes from the backend's UDS-bound + * Fastify server through a `ParcelFileDescriptor` pipe. + * + * iOS → `comapeo://media/`, intercepted by `MediaURLProtocol` + * (registered globally on first `AppLifecycleDelegate` instantiation) which + * connects the same UDS and streams the response into the URL loader. + * + * @param relativePath A path beginning with `/`, e.g. `/blobs//.../filename.jpg`. + * Pass straight from the backend RPC result. + */ +export function toNativeMediaUrl(relativePath: string): string { + if (!relativePath.startsWith("/")) { + throw new Error( + `Expected relative media path beginning with '/', got: ${relativePath}`, + ); + } + + if (Platform.OS === "android") { + return `content://${androidAuthority()}${relativePath}`; + } + if (Platform.OS === "ios") { + return `comapeo://media${relativePath}`; + } + throw new Error(`toNativeMediaUrl is not supported on ${Platform.OS}`); +} From dfc121463729555501187efb06afa9a230dfdbc7 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 15:22:10 +0100 Subject: [PATCH 2/7] fix(backend): regenerate lockfile with npm 11 to keep esbuild platform deps The previous commit's lockfile was regenerated with npm 10 (Node 20.19.4) which dropped the `@esbuild/*` optional platform packages. CI runs `npm ci --ignore-scripts --prefix backend` on Node 24 / npm 11 (per `devEngines.runtime`) and rejected the lockfile with `Missing: @esbuild/@0.28.0 from lock file`. Even where `npm ci` got past that, the rollup-plugin-esbuild step then failed at "The package @esbuild/darwin-arm64 could not be found, and is needed by esbuild" because the platform binary was never installed. Regenerate with the canonical Node 24 / npm 11 toolchain so the optional platform entries stay in the lockfile and `npm ci --ignore-scripts` materialises the macOS binary alongside everything else. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/package-lock.json | 452 +++++++++++++++++++++++++++++++++++++- 1 file changed, 451 insertions(+), 1 deletion(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 40cbbf9..fbcb9bd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -418,6 +418,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -1896,7 +2338,8 @@ "version": "0.33.22", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.22.tgz", "integrity": "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", @@ -2512,6 +2955,7 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -2599,6 +3043,7 @@ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -2973,6 +3418,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -5741,6 +6187,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6327,6 +6774,7 @@ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7148,6 +7596,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -7461,6 +7910,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From a85defeb73d6470e6233a5c08068446ab9352400 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 16:58:15 +0100 Subject: [PATCH 3/7] example: add media URL test fixture screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the trivial \"projects: 0\" demo with a screen that exercises the full media-URL stack end to end: - find/create a fixture project - materialise the bundled icon.png to a real filesystem path via expo-asset (file:// then strip scheme — backend wants a plain path) - $blobs.create({ original: filepath }, { mimeType: 'image/png' }) - $blobs.getUrl(blobId) — should return /blobs//... after the @comapeo/core patch in this PR - toNativeMediaUrl() rewrite to content://… (Android) or comapeo://media/… (iOS) - renders the result Both URLs are also rendered as selectable monospace text so a reviewer can eyeball them without DevTools. \"Reload \" forces a remount (via key) so the loader re-fetches instead of reusing the in-memory cache; \"Re-create blob\" walks the whole pipeline again. Adds expo-asset (12.0.10) as an example-only dependency. Not added to the published @comapeo/core-react-native package — only the test fixture needs it. Co-Authored-By: Claude Opus 4.7 (1M context) --- example/App.tsx | 318 ++++++++++++++++++++++++++++--- example/package-lock.json | 392 ++++++++++++++++++++++++++++++++++++-- example/package.json | 1 + 3 files changed, 670 insertions(+), 41 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index d080f99..3c3b387 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,65 +1,325 @@ -import { comapeo } from "@comapeo/core-react-native"; -import React, { useEffect, useState } from "react"; -import { ScrollView, Text, View } from "react-native"; +import { comapeo, toNativeMediaUrl } from "@comapeo/core-react-native"; +import { Asset } from "expo-asset"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Image, + Platform, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -let renderCount = 0; +/** + * Test fixture for the UDS-bound media server. + * + * Walks through the full path that PR #44 introduces: + * 1. Pick or create a project (so we have a `MapeoProject` to call + * `$blobs.create()` on). + * 2. Resolve a bundled image to a real filesystem path that the embedded + * Node.js process can read. On iOS this lands inside the `.app` + * bundle; on Android `Asset.downloadAsync()` copies the asset out of + * the APK into the app's data dir on first call, then both processes + * (main app + `:ComapeoCore` service) share the same UID/sandbox so + * the backend can read it. + * 3. `$blobs.create({ original: filepath }, { mimeType })` — round-trips + * bytes into the hyperdrive blob store. + * 4. `$blobs.getUrl(blobId)` returns a relative path like + * `/blobs//.../` (after the @comapeo/core + * patch shipped in this PR). + * 5. `toNativeMediaUrl()` rewrites that to `content://...` (Android) or + * `comapeo://media/...` (iOS). + * 6. `` renders it. If we see the + * bundled test image, the whole stack — UDS bind → ContentProvider / + * URLProtocol → JS bridge — works end to end. + * + * The two URLs (raw and rewritten) are also rendered as text so a + * reviewer can eyeball them without DevTools. + */ +type Phase = + | { kind: "loading"; message: string } + | { kind: "error"; message: string } + | { + kind: "ready"; + relativeUrl: string; + nativeUrl: string; + projectId: string; + }; + +const PROJECT_NAME = "media-url-fixture"; export default function App() { - const [projects, setProjects] = useState([]); + const [phase, setPhase] = useState({ + kind: "loading", + message: "Booting backend…", + }); + const [renderTick, setRenderTick] = useState(0); - useEffect(() => { - comapeo.listProjects().then(setProjects); + const run = useCallback(async () => { + setPhase({ kind: "loading", message: "Boot backend + list projects" }); + + try { + const projects = await comapeo.listProjects(); + let projectId = projects.find((p) => p.name === PROJECT_NAME)?.projectId; + if (!projectId) { + setPhase({ kind: "loading", message: "Creating fixture project…" }); + projectId = await comapeo.createProject({ name: PROJECT_NAME }); + } + + setPhase({ kind: "loading", message: "Materialising bundled asset…" }); + // require() is the canonical way to ship a bundled asset in + // React Native. expo-asset's `downloadAsync()` resolves to a + // localUri the backend can stat() and fopen(). + const asset = Asset.fromModule(require("./assets/icon.png")); + await asset.downloadAsync(); + if (!asset.localUri) { + throw new Error("Asset.downloadAsync produced no localUri"); + } + // Strip the `file://` scheme — `BlobApi.create` wants a plain path. + const filepath = asset.localUri.replace(/^file:\/\//, ""); + + setPhase({ kind: "loading", message: "Saving blob into project…" }); + const project = await comapeo.getProject(projectId); + const created = await project.$blobs.create( + { original: filepath }, + { mimeType: "image/png" }, + ); + + setPhase({ kind: "loading", message: "Fetching blob URL…" }); + const relativeUrl = await project.$blobs.getUrl({ + driveId: created.driveId, + type: created.type, + variant: "original", + name: created.name, + }); + + const nativeUrl = toNativeMediaUrl(relativeUrl); + + setPhase({ + kind: "ready", + projectId, + relativeUrl, + nativeUrl, + }); + } catch (e) { + setPhase({ + kind: "error", + message: e instanceof Error ? `${e.message}\n${e.stack}` : String(e), + }); + } }, []); + useEffect(() => { + run(); + }, [run]); + return ( - + - Module API Example + Media URL test fixture + Platform: {Platform.OS} - - {projects.length} - - - {renderCount++} - + setRenderTick((n) => n + 1)} + onRetry={run} + /> ); } -function Group(props: { name: string; children: React.ReactNode }) { +function PhaseSection({ + phase, + renderTick, + onReload, + onRetry, +}: { + phase: Phase; + renderTick: number; + onReload: () => void; + onRetry: () => void; +}) { + if (phase.kind === "loading") { + return ( + + + {phase.message} + + ); + } + if (phase.kind === "error") { + return ( + + Error + + {phase.message} + + + + ); + } + return ( + + ); +} + +function ReadyView({ + phase, + renderTick, + onReload, + onRetry, +}: { + phase: Extract; + renderTick: number; + onReload: () => void; + onRetry: () => void; +}) { + const imageSource = useMemo( + () => ({ uri: phase.nativeUrl }), + // `renderTick` lets the user force-remount the via the Reload + // button to confirm the URL is fetched (and not just served from a + // memory cache from a prior boot of the screen). + // eslint-disable-next-line react-hooks/exhaustive-deps + [phase.nativeUrl, renderTick], + ); + return ( - - {props.name} - {props.children} + + + Project + + {phase.projectId} + + + + + Relative URL (from backend) + + {phase.relativeUrl} + + + + + Native URL (after toNativeMediaUrl) + + {phase.nativeUrl} + + + + + + Rendered <Image> (#{renderTick + 1}) + + { + // Surfaces in dev menu / logcat / Console.app — invaluable + // when the URLProtocol or ContentProvider misbehaves. + // eslint-disable-next-line no-console + console.warn("Image load failed", e.nativeEvent); + }} + /> + + + + + ); } +function PressableButton({ + label, + onPress, +}: { + label: string; + onPress: () => void; +}) { + return ( + + {label} + + ); +} + const styles = { header: { - fontSize: 30, - margin: 20, + fontSize: 26, + margin: 16, + fontWeight: "600" as const, }, - groupHeader: { - fontSize: 20, - marginBottom: 20, + platform: { + fontSize: 14, + marginHorizontal: 16, + marginBottom: 8, + color: "#444", }, group: { - margin: 20, + margin: 12, backgroundColor: "#fff", borderRadius: 10, - padding: 20, + padding: 16, + }, + errorGroup: { + backgroundColor: "#ffe4e4", + }, + groupHeader: { + fontSize: 16, + fontWeight: "600" as const, + marginBottom: 8, + }, + body: { + fontSize: 14, + }, + mono: { + fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }), + fontSize: 12, + }, + image: { + width: 256, + height: 256, + backgroundColor: "#eee", + borderRadius: 8, + }, + buttonRow: { + flexDirection: "row" as const, + flexWrap: "wrap" as const, + gap: 8, + marginTop: 12, + }, + button: { + backgroundColor: "#0a84ff", + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 8, + }, + buttonLabel: { + color: "#fff", + fontWeight: "600" as const, }, container: { flex: 1, backgroundColor: "#eee", }, - view: { - flex: 1, - height: 200, + contentContainer: { + paddingBottom: 40, }, }; diff --git a/example/package-lock.json b/example/package-lock.json index 32ba40d..b268d65 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "expo": "55.0.17", + "expo-asset": "12.0.10", "react": "19.2.5", "react-native": "0.83.6", "react-native-safe-area-context": "5.6.2" @@ -47,6 +48,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -344,6 +346,92 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/parser": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", @@ -1327,6 +1415,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -2367,6 +2456,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2510,6 +2600,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2868,6 +2964,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3315,6 +3412,33 @@ "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3417,6 +3541,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-55.0.17.tgz", "integrity": "sha512-yVF2phiPw5XgOCedC/oQaL3j0XbwzsBLst3JiAF8bi9aFlxLOVvuDEM8BDg3E09XGSLaGCAclY4q5L+sFerXlQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "55.0.26", @@ -3466,6 +3591,129 @@ } } }, + "node_modules/expo-asset": { + "version": "12.0.10", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.10.tgz", + "integrity": "sha512-pZyeJkoDsALh4gpCQDzTA/UCLaPH/1rjQNGubmLn/uDM27S4iYJb/YWw4+CNZOtd5bCUOhDPg5DtGQnydNFSXg==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.7", + "expo-constants": "~18.0.10" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/expo-constants/node_modules/@expo/config": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "~3.35.1" + } + }, + "node_modules/expo-constants/node_modules/@expo/config-plugins": { + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-constants/node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "license": "MIT" + }, + "node_modules/expo-constants/node_modules/@expo/env": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", + "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + } + }, + "node_modules/expo-constants/node_modules/@expo/plist": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", + "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo-constants/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-modules-autolinking": { "version": "55.0.18", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-55.0.18.tgz", @@ -3505,6 +3753,7 @@ "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.8.tgz", "integrity": "sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.16.0" } @@ -3827,6 +4076,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.15.tgz", "integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==", "license": "MIT", + "peer": true, "dependencies": { "@expo/env": "~2.1.1" }, @@ -3850,6 +4100,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.6.tgz", "integrity": "sha512-x9czUA3UQWjIwa0ZUEs/eWJNqB4mAue/m4ltESlNPLZhHL0nWWqIfsyHmklTLFH7mVfcHSJvew6k+pR2FE1zVw==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -3933,6 +4184,23 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-nodeshim": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", @@ -4765,9 +5033,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4788,9 +5053,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4811,9 +5073,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4834,9 +5093,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4890,6 +5146,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -5500,6 +5762,17 @@ "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5602,6 +5875,15 @@ "node": ">=20.19.4" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -6003,6 +6285,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6183,6 +6466,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6255,6 +6539,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6805,6 +7098,37 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6939,12 +7263,49 @@ "node": "*" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/throat": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6978,6 +7339,12 @@ "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", "license": "MIT" }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -7002,6 +7369,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/example/package.json b/example/package.json index cd0fea0..986eab5 100644 --- a/example/package.json +++ b/example/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "expo": "55.0.17", + "expo-asset": "12.0.10", "react": "19.2.5", "react-native": "0.83.6", "react-native-safe-area-context": "5.6.2" From a5d3c78847073c4696d846268d4ce24035384460 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 17:14:55 +0100 Subject: [PATCH 4/7] fix(example): use expo-asset 55.x to match SDK 55 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expo-asset@12.0.10` is the SDK 54 line; pulling it under expo@55.0.17 forced a transitively-resolved expo-constants@18.0.13 alongside the project's expo-modules-core@55.0.23, and the two snapshots disagree on the abstract members of `ConstantsService`. Gradle's `:expo-constants:compileDebugKotlin` then failed with "'getConstants' overrides nothing", "Unresolved reference 'constants'", etc. Pin to expo-asset@55.0.16 — the version expo@55.0.17 declares in its own `dependencies` field — so every expo-* package resolves to the same 55.x snapshot. iOS Device Build also failed in the same run with `curl: (56) The requested URL returned error: 502` from a transient prebuild fetch. Not fixed here; it should clear on rerun. Co-Authored-By: Claude Opus 4.7 (1M context) --- example/package-lock.json | 390 ++------------------------------------ example/package.json | 2 +- 2 files changed, 12 insertions(+), 380 deletions(-) diff --git a/example/package-lock.json b/example/package-lock.json index b268d65..111ed44 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "expo": "55.0.17", - "expo-asset": "12.0.10", + "expo-asset": "55.0.16", "react": "19.2.5", "react-native": "0.83.6", "react-native-safe-area-context": "5.6.2" @@ -346,92 +346,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", @@ -2600,12 +2514,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3412,33 +3320,6 @@ "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", "license": "MIT" }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3592,13 +3473,13 @@ } }, "node_modules/expo-asset": { - "version": "12.0.10", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.10.tgz", - "integrity": "sha512-pZyeJkoDsALh4gpCQDzTA/UCLaPH/1rjQNGubmLn/uDM27S4iYJb/YWw4+CNZOtd5bCUOhDPg5DtGQnydNFSXg==", + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-55.0.16.tgz", + "integrity": "sha512-5IJyfJtYqvKGg04NKGQWiCIoK/fULDL9m15mXPPyfabD1jsToVj2hnWmo1r2SWNNmMwtQxi6jTpcGwVo2nLDxg==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.7", - "expo-constants": "~18.0.10" + "@expo/image-utils": "^0.8.13", + "expo-constants": "~55.0.15" }, "peerDependencies": { "expo": "*", @@ -3607,113 +3488,19 @@ } }, "node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.15.tgz", + "integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==", "license": "MIT", + "peer": true, "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" + "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, - "node_modules/expo-constants/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/expo-constants/node_modules/@expo/config": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", - "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/json-file": "^10.0.8", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "~3.35.1" - } - }, - "node_modules/expo-constants/node_modules/@expo/config-plugins": { - "version": "54.0.4", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", - "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", - "license": "MIT", - "dependencies": { - "@expo/config-types": "^54.0.10", - "@expo/json-file": "~10.0.8", - "@expo/plist": "^0.4.8", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slash": "^3.0.0", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "node_modules/expo-constants/node_modules/@expo/config-types": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", - "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", - "license": "MIT" - }, - "node_modules/expo-constants/node_modules/@expo/env": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", - "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0" - } - }, - "node_modules/expo-constants/node_modules/@expo/plist": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", - "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" - } - }, - "node_modules/expo-constants/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/expo-modules-autolinking": { "version": "55.0.18", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-55.0.18.tgz", @@ -4056,35 +3843,6 @@ } } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "55.0.16", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-55.0.16.tgz", - "integrity": "sha512-5IJyfJtYqvKGg04NKGQWiCIoK/fULDL9m15mXPPyfabD1jsToVj2hnWmo1r2SWNNmMwtQxi6jTpcGwVo2nLDxg==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.13", - "expo-constants": "~55.0.15" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-constants": { - "version": "55.0.15", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.15.tgz", - "integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/env": "~2.1.1" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { "version": "55.0.17", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.17.tgz", @@ -4184,23 +3942,6 @@ "bser": "2.1.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fetch-nodeshim": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", @@ -5146,12 +4887,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -5762,17 +5497,6 @@ "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5875,15 +5599,6 @@ "node": ">=20.19.4" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -6539,15 +6254,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -7098,37 +6804,6 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7263,49 +6938,12 @@ "node": "*" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/throat": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7339,12 +6977,6 @@ "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", "license": "MIT" }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/example/package.json b/example/package.json index 986eab5..0d14c4a 100644 --- a/example/package.json +++ b/example/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "expo": "55.0.17", - "expo-asset": "12.0.10", + "expo-asset": "55.0.16", "react": "19.2.5", "react-native": "0.83.6", "react-native-safe-area-context": "5.6.2" From ee354a8cbe4311ff29753198222fac353a9a7dfd Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 17:59:09 +0100 Subject: [PATCH 5/7] fix(ios): add RCTImageURLLoader so React Native can load comapeo:// URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `URLProtocol.registerClass(_:)` only catches `URLSession`-backed callers. RN's `RCTImageLoader` does its own scheme→loader lookup *first* and never reaches `URLSession` for unknown schemes, so the example app errored on every with "No suitable image URL loader found for comapeo://media/...". Extract the UDS connect → write → header-parse pipeline into a shared `MediaFetcher` Swift class (`@objc(ComapeoMediaFetcher)`), and add `ComapeoMediaImageLoader.mm` that: - registers via `RCT_EXPORT_MODULE()` so RN picks it up at +load time - claims `comapeo://media/...` from `canLoadImageURL:` with priority 1 - buffers the body via `MediaFetcher.fetchURL:completion:` and decodes a UIImage for `completionHandler` Memory shape matches RN's existing `RCTNetworkImageLoader` for http(s): both buffer encoded NSData then call `[UIImage imageWithData:]`. RN has no incremental-decode path for static images, so the URL-protocol streaming was a theoretical optimization the image loader never used. The streaming `URLProtocol` stays registered for any non-RN-Image caller (share sheet, third-party libs, future SDWebImage hookup) — both paths share the new `MediaFetcher.open()` helper. `AppLifecycleDelegate` installs the socket-path closure on `MediaFetcher.socketPathProvider` (the new single source of truth) and `MediaURLProtocol` reads from there too. Header import in the .mm uses `__has_include` to cover both framework-style (``) and static-lib (`"ComapeoCore-Swift.h"`) Pod build configurations. Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/AppLifecycleDelegate.swift | 13 +- ios/ComapeoMediaImageLoader.mm | 119 +++++++++++++ ios/MediaFetcher.swift | 301 +++++++++++++++++++++++++++++++++ ios/MediaURLProtocol.swift | 229 +++---------------------- 4 files changed, 454 insertions(+), 208 deletions(-) create mode 100644 ios/ComapeoMediaImageLoader.mm create mode 100644 ios/MediaFetcher.swift diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index fe5793a..72c4749 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -23,13 +23,22 @@ import UIKit /// results, see `src/mediaUrl.ts`) stream straight from the backend's /// UDS-bound HTTP server into React Native image components. public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { - /// Idempotent installer for the global URL protocol — `static let` + /// Idempotent installer for the media URL fetch path — `static let` /// guarantees at-most-one registration regardless of how many /// `AppLifecycleDelegate` instances Expo creates. Side-effect happens /// the first time anything reads this property; the value itself is /// only there to make the body run. + /// + /// Two consumers wire up here: + /// - `MediaFetcher.socketPathProvider` — read by the Obj-C + /// `ComapeoMediaImageLoader` (`RCTImageURLLoader`) which RN's + /// `` looks up by scheme, AND by the streaming + /// `MediaURLProtocol` below. + /// - `URLProtocol.registerClass(MediaURLProtocol.self)` — picks up + /// `URLSession.shared` callers (share sheet, third-party libs) + /// for which the RCTImageURLLoader path is irrelevant. private static let _mediaUrlProtocolInstalled: Bool = { - MediaURLProtocol.mediaSocketPathProvider = { + MediaFetcher.socketPathProvider = { AppLifecycleDelegate.nodeService.mediaSocketPath } URLProtocol.registerClass(MediaURLProtocol.self) diff --git a/ios/ComapeoMediaImageLoader.mm b/ios/ComapeoMediaImageLoader.mm new file mode 100644 index 0000000..68be93c --- /dev/null +++ b/ios/ComapeoMediaImageLoader.mm @@ -0,0 +1,119 @@ +// React Native's RCTImageLoader looks up a registered RCTImageURLLoader +// by scheme BEFORE ever touching URLSession — so a globally-registered +// URLProtocol alone gives "No suitable image URL loader found for +// comapeo://...". This file plugs in to that lookup so RN's built-in +// can fetch our `comapeo://media/...` URLs. +// +// Implemented in Obj-C++ (.mm) so: +// - RCT_EXPORT_MODULE() macro is available (RN's autolinking glue +// needs an Obj-C class on the +load runtime hook). +// - We can `#import "ComapeoCore-Swift.h"` to call into the shared +// Swift `MediaFetcher` (same UDS-fetch logic that backs the +// streaming URLProtocol). +// +// Streaming-vs-buffered: this loader buffers the full body into NSData +// because `[UIImage imageWithData:]` requires it. That matches RN's +// existing `RCTNetworkImageLoader` for http(s) — same peak memory +// shape. See MediaFetcher.swift for the streaming variant used by +// non-RN-Image consumers. + +#import +#import +#import + +// The auto-generated Swift bridging header lands in different paths +// depending on whether CocoaPods built ComapeoCore as a framework or a +// static library; cover both. `__has_include` is a Clang preprocessor +// check that resolves at compile time without erroring on the unmet +// branch. +#if __has_include() +#import +#elif __has_include("ComapeoCore-Swift.h") +#import "ComapeoCore-Swift.h" +#endif + +@interface ComapeoMediaImageLoader : NSObject +@end + +@implementation ComapeoMediaImageLoader + +RCT_EXPORT_MODULE() + +- (BOOL)canLoadImageURL:(NSURL *)requestURL +{ + return [ComapeoMediaFetcher canHandle:requestURL]; +} + +- (float)loaderPriority +{ + // Higher than the default (0) so we win over any future loader that + // also claims this scheme. Nothing else in RN's tree matches + // `comapeo://` today, but being explicit is cheap. + return 1.0; +} + +- (BOOL)requiresScheduling +{ + // Defaults to YES, which routes us through RCTImageLoader's serial + // url-cache queue. We have nothing to gain from that throttling — + // the UDS pipe is already inside the app sandbox — and bypassing + // the scheduler lets concurrent s fan out to MediaFetcher + // independently. + return NO; +} + +- (RCTImageLoaderCancellationBlock)loadImageForURL:(NSURL *)imageURL + size:(CGSize)size + scale:(CGFloat)scale + resizeMode:(RCTResizeMode)resizeMode + progressHandler:(RCTImageLoaderProgressBlock)progressHandler + partialLoadHandler:(RCTImageLoaderPartialLoadBlock)partialLoadHandler + completionHandler:(RCTImageLoaderCompletionBlock)completionHandler +{ + // The cancellation token wraps a heap-allocated flag so both the + // returned cancel block and the fetcher completion can read/write + // it without retaining each other. + __block BOOL cancelled = NO; + NSObject *cancelLock = [NSObject new]; + + [ComapeoMediaFetcher fetchURL:imageURL + completion:^(NSData * _Nullable data, NSError * _Nullable error) { + @synchronized (cancelLock) { + if (cancelled) return; + } + + if (error) { + completionHandler(error, nil); + return; + } + if (data == nil) { + completionHandler( + [NSError errorWithDomain:@"ComapeoMediaImageLoader" + code:0 + userInfo:@{NSLocalizedDescriptionKey: + @"MediaFetcher returned no data and no error"}], + nil); + return; + } + + UIImage *image = [UIImage imageWithData:data scale:scale]; + if (image == nil) { + completionHandler( + [NSError errorWithDomain:@"ComapeoMediaImageLoader" + code:1 + userInfo:@{NSLocalizedDescriptionKey: + @"UIImage failed to decode response body"}], + nil); + return; + } + completionHandler(nil, image); + }]; + + return ^{ + @synchronized (cancelLock) { + cancelled = YES; + } + }; +} + +@end diff --git a/ios/MediaFetcher.swift b/ios/MediaFetcher.swift new file mode 100644 index 0000000..2ad2683 --- /dev/null +++ b/ios/MediaFetcher.swift @@ -0,0 +1,301 @@ +import Foundation + +/// Shared UDS-fetch implementation for `comapeo://media/...` URLs. +/// +/// Two consumers: +/// +/// 1. `MediaURLProtocol` (streaming) — registered globally via +/// `URLProtocol.registerClass(_:)` so any `URLSession.shared`-backed +/// caller (share sheet, third-party libraries) gets the body chunked +/// into the URL loader without buffering. +/// +/// 2. `ComapeoMediaImageLoader` (Obj-C `RCTImageURLLoader`) — required for +/// React Native's built-in ``. RN's `RCTImageLoader` looks up +/// a registered `RCTImageURLLoader` by scheme **before** ever +/// touching `URLSession`, so a `URLProtocol` alone gives +/// "No suitable image URL loader found for comapeo://...". The +/// loader buffers the whole body into `NSData` (it must, to call +/// `[UIImage imageWithData:]`) and decodes a `UIImage` for the +/// completion handler. +/// +/// Both call into the same `connect → write request → parse headers → +/// drain body` pipeline; the only difference is whether the body is +/// streamed in chunks or accumulated in a `Data`. +/// +/// **HTTP/1.0 by design.** Forces Fastify into "Connection: close, body +/// delimited by EOF" mode so neither code path needs a chunked-encoding +/// state machine. Trade-off is no keep-alive, fine for our request volume. +@objc(ComapeoMediaFetcher) +public final class MediaFetcher: NSObject { + /// Source for the path the backend's Fastify HTTP server is bound to. + /// `AppLifecycleDelegate` installs the closure pointing at + /// `NodeJSService.mediaSocketPath` once the service is constructed. + /// `nil` → backend not booted yet; callers see a clear error instead of + /// a hang. + public static var socketPathProvider: (() -> String?)? + + public static let scheme = "comapeo" + public static let host = "media" + + /// Connection-attempt budget. Image loads happen most often as part of + /// a list scroll; if the backend isn't up after ~10 s of retry, + /// hanging the request further would only make the UI worse. + static let connectMaxRetries = 5 + + /// `true` if `request.url` matches the scheme this fetcher handles. + @objc public static func canHandle(_ url: URL) -> Bool { + return url.scheme?.lowercased() == scheme && url.host?.lowercased() == host + } + + /// Buffered fetch — drives the whole pipeline, returning the body as a + /// single `Data`. Used by the Obj-C `RCTImageURLLoader` (which has to + /// hand `UIImage` a `Data` anyway). For streaming, see + /// `MediaURLProtocol`. + /// + /// `completion` is invoked on a background queue. + @objc(fetchURL:completion:) + public static func fetch( + url: NSURL, + completion: @escaping (Data?, NSError?) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let data = try fetchSync(url: url as URL) + completion(data, nil) + } catch let error as NSError { + completion(nil, error) + } + } + } + + /// Synchronous variant. Throws the underlying `URLError` (or a wrapped + /// `NSError`) on any failure. + public static func fetchSync(url: URL) throws -> Data { + let opened = try open(url: url) + defer { close(opened.fd) } + + guard (200..<300).contains(opened.status) else { + throw URLError( + .fileDoesNotExist, + userInfo: [NSLocalizedDescriptionKey: + "HTTP \(opened.status) for \(url.path)"] + ) + } + + var body = opened.bodyTail + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let n = Darwin.read(opened.fd, &buffer, buffer.count) + if n > 0 { + body.append(buffer, count: n) + continue + } + if n == 0 { return body } + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { + var pfd = pollfd(fd: opened.fd, events: Int16(POLLIN), revents: 0) + _ = Darwin.poll(&pfd, 1, 5000) + continue + } + throw URLError(.networkConnectionLost, + userInfo: [NSLocalizedDescriptionKey: "read errno \(errno)"]) + } + } + + /// Connects the UDS, writes the HTTP/1.0 request, parses the status + /// line + headers, and returns an open fd positioned at the first + /// body byte plus any tail bytes that landed in the same read. + /// Closing `fd` is the caller's responsibility. + static func open(url: URL) throws -> OpenedResponse { + guard let socketPath = socketPathProvider?() else { + throw URLError(.cannotConnectToHost, + userInfo: [NSLocalizedDescriptionKey: + "Media socket path not configured"]) + } + + let pathAndQuery = (url.path.isEmpty ? "/" : url.path) + + (url.query.map { "?\($0)" } ?? "") + + let fd: Int32 + do { + fd = try connectWithRetry(socketPath: socketPath, + maxRetries: connectMaxRetries) + } catch { + throw URLError(.cannotConnectToHost, + userInfo: [NSUnderlyingErrorKey: error, + NSLocalizedDescriptionKey: + error.localizedDescription]) + } + + do { + let httpRequest = + "GET \(pathAndQuery) HTTP/1.0\r\n" + + "Host: localhost\r\n" + + "Connection: close\r\n" + + "\r\n" + guard let bytes = httpRequest.data(using: .ascii) else { + throw URLError(.badURL) + } + try writeAll(fd: fd, data: bytes) + let headers = try readHeaders(fd: fd) + return OpenedResponse( + fd: fd, + status: headers.status, + headers: headers.headers, + bodyTail: headers.bodyTail + ) + } catch { + close(fd) + throw error + } + } + + struct OpenedResponse { + let fd: Int32 + let status: Int + let headers: [String: String] + /// Body bytes that landed in the same read as the end-of-headers + /// marker. Caller must consume these before continuing to read fd. + let bodyTail: Data + } + + private struct HeaderParseResult { + let status: Int + let headers: [String: String] + let bodyTail: Data + } + + /// Loops `write(2)` until all bytes are sent. Handles `EINTR` and + /// `EAGAIN`/`EWOULDBLOCK` (the latter via `poll`). + private static func writeAll(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { (rawBuf: UnsafeRawBufferPointer) in + guard let base = rawBuf.baseAddress else { + throw URLError(.badURL) + } + var written = 0 + while written < data.count { + let n = Darwin.write(fd, base.advanced(by: written), + data.count - written) + if n > 0 { written += n; continue } + if n < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { + var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + _ = Darwin.poll(&pfd, 1, 5000) + continue + } + throw URLError(.networkConnectionLost, + userInfo: [NSLocalizedDescriptionKey: + "write errno \(errno)"]) + } + // n == 0 → peer closed. + throw URLError(.networkConnectionLost) + } + } + } + + /// Reads bytes from `fd` until the CRLFCRLF that ends the header + /// section, parses status line + headers, returns any body bytes that + /// landed in the same read. + private static func readHeaders(fd: Int32) throws -> HeaderParseResult { + var buf = Data() + let chunk = 4096 + var scratch = [UInt8](repeating: 0, count: chunk) + let terminator: [UInt8] = [0x0d, 0x0a, 0x0d, 0x0a] // \r\n\r\n + let maxHeaderBytes = 64 * 1024 + + while true { + // Bound-check: a runaway server should not be able to make us + // allocate megabytes of header. + if buf.count > maxHeaderBytes { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: + "Header section too large"]) + } + let n = Darwin.read(fd, &scratch, chunk) + if n > 0 { + buf.append(scratch, count: n) + if let endIndex = findTerminator(in: buf, terminator: terminator) { + let headerData = buf[.. Int? { + guard data.count >= terminator.count else { return nil } + return data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Int? in + let bytes = ptr.bindMemory(to: UInt8.self) + outer: for i in 0...(data.count - terminator.count) { + for j in 0.. (Int, [String: String]) { + guard let raw = String(data: headerData, encoding: .isoLatin1) else { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: + "Header bytes not parseable"]) + } + // Split on CRLF; tolerate bare LF for safety. + let lines = raw.split(whereSeparator: { $0 == "\r" || $0 == "\n" }) + .map { String($0) } + .filter { !$0.isEmpty } + guard let statusLine = lines.first else { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: "Empty header section"]) + } + let parts = statusLine.split(separator: " ", maxSplits: 2) + guard parts.count >= 2, let status = Int(parts[1]) else { + throw URLError(.badServerResponse, + userInfo: [NSLocalizedDescriptionKey: + "Malformed status line: \(statusLine)"]) + } + var headers: [String: String] = [:] + for line in lines.dropFirst() { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[.. String? { + switch ext.lowercased() { + case "jpg", "jpeg": return "image/jpeg" + case "png": return "image/png" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "svg": return "image/svg+xml" + case "heic": return "image/heic" + default: return nil + } + } +} diff --git a/ios/MediaURLProtocol.swift b/ios/MediaURLProtocol.swift index 2291fff..b965730 100644 --- a/ios/MediaURLProtocol.swift +++ b/ios/MediaURLProtocol.swift @@ -25,21 +25,6 @@ import Foundation /// socket down; the loop notices on the next read and exits without /// emitting a completion or error to the now-dead client. class MediaURLProtocol: URLProtocol { - /// Source for the path the backend's Fastify HTTP server is bound to. - /// `AppLifecycleDelegate` installs the closure pointing at - /// `NodeJSService.mediaSocketPath` once the service is constructed. - /// A nil value means we haven't booted yet — clients see a clear error - /// instead of a hang. - static var mediaSocketPathProvider: (() -> String?)? - - static let scheme = "comapeo" - static let host = "media" - - /// Connection-attempt budget. Image loads happen most often as part of a - /// list scroll; if the backend isn't up after ~10 s of retry, hanging - /// the request further would only make the UI worse. - private static let connectMaxRetries = 5 - private let workQueue = DispatchQueue(label: "com.comapeo.core.media.url-protocol") private var fd: Int32 = -1 private let stateLock = NSLock() @@ -47,7 +32,7 @@ class MediaURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { guard let url = request.url else { return false } - return url.scheme?.lowercased() == scheme && url.host?.lowercased() == host + return MediaFetcher.canHandle(url) } override class func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -77,21 +62,12 @@ class MediaURLProtocol: URLProtocol { return } - guard let socketPath = MediaURLProtocol.mediaSocketPathProvider?() else { - failClient(.init(.cannotConnectToHost, - userInfo: [NSLocalizedDescriptionKey: "Media socket path not configured"])) - return - } - - let pathAndQuery = (url.path.isEmpty ? "/" : url.path) - + (url.query.map { "?\($0)" } ?? "") - - let connectedFd: Int32 + let opened: MediaFetcher.OpenedResponse do { - connectedFd = try connectWithRetry( - socketPath: socketPath, - maxRetries: MediaURLProtocol.connectMaxRetries - ) + opened = try MediaFetcher.open(url: url) + } catch let error as URLError { + failClient(error) + return } catch { failClient(.init(.cannotConnectToHost, userInfo: [NSUnderlyingErrorKey: error, @@ -100,13 +76,13 @@ class MediaURLProtocol: URLProtocol { } stateLock.lock() - // Honour stopLoading() that arrived during connectWithRetry. + // Honour stopLoading() that arrived during MediaFetcher.open(). guard !isCancelled else { stateLock.unlock() - close(connectedFd) + close(opened.fd) return } - fd = connectedFd + fd = opened.fd stateLock.unlock() defer { @@ -117,48 +93,20 @@ class MediaURLProtocol: URLProtocol { if toClose >= 0 { close(toClose) } } - let httpRequest = - "GET \(pathAndQuery) HTTP/1.0\r\n" - + "Host: localhost\r\n" - + "Connection: close\r\n" - + "\r\n" - guard let requestBytes = httpRequest.data(using: .ascii) else { - failClient(.init(.badURL)) - return - } - - if !writeAll(connectedFd, data: requestBytes) { - if !cancelled() { - failClient(.init(.networkConnectionLost)) - } - return - } - - let headerResult: HeaderParseResult - do { - headerResult = try readHeaders(fd: connectedFd) - } catch { - if !cancelled() { - failClient(.init(.badServerResponse, - userInfo: [NSLocalizedDescriptionKey: error.localizedDescription])) - } - return - } - - guard (200..<300).contains(headerResult.status) else { + guard (200..<300).contains(opened.status) else { failClient(.init(.fileDoesNotExist, userInfo: [NSLocalizedDescriptionKey: - "HTTP \(headerResult.status) for \(pathAndQuery)"])) + "HTTP \(opened.status) for \(url.path)"])) return } - // Build a fake URLResponse — image loaders only look at MIME type - // and (when present) expectedContentLength, so we don't need to - // round-trip every header. - let mimeType = headerResult.headers["content-type"] - ?? mimeFromExtension(url.pathExtension) + // Build a URLResponse — image loaders only look at MIME type and + // (when present) expectedContentLength, so we don't round-trip + // every header. + let mimeType = opened.headers["content-type"] + ?? MediaFetcher.mimeFromExtension(url.pathExtension) ?? "application/octet-stream" - let length = headerResult.headers["content-length"].flatMap(Int.init) ?? -1 + let length = opened.headers["content-length"].flatMap(Int.init) ?? -1 let response = URLResponse( url: url, @@ -168,15 +116,15 @@ class MediaURLProtocol: URLProtocol { ) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - // Flush any over-read body bytes from the header-parsing buffer first, - // then drain the socket to EOF. - if !headerResult.bodyTail.isEmpty { - client?.urlProtocol(self, didLoad: headerResult.bodyTail) + // Flush any body bytes that came in with the headers' final read, + // then drain the socket to EOF chunk by chunk. + if !opened.bodyTail.isEmpty { + client?.urlProtocol(self, didLoad: opened.bodyTail) } var buffer = [UInt8](repeating: 0, count: 64 * 1024) while !cancelled() { - let n = Darwin.read(connectedFd, &buffer, buffer.count) + let n = Darwin.read(opened.fd, &buffer, buffer.count) if n > 0 { client?.urlProtocol(self, didLoad: Data(buffer[0.. Bool { - return data.withUnsafeBytes { rawBuf -> Bool in - guard let base = rawBuf.baseAddress else { return false } - var written = 0 - while written < data.count { - let n = Darwin.write(fd, base.advanced(by: written), data.count - written) - if n > 0 { written += n; continue } - if n < 0 { - if errno == EINTR { continue } - if errno == EAGAIN || errno == EWOULDBLOCK { - var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) - _ = Darwin.poll(&pfd, 1, 5000) - continue - } - return false - } - return false - } - return true - } - } - - private struct HeaderParseResult { - let status: Int - let headers: [String: String] - /// Body bytes that were over-read while looking for the end-of-headers - /// marker. Must be flushed to the URL loader before draining the socket. - let bodyTail: Data - } - - /// Reads bytes from `fd` until the end of the HTTP header section - /// (CRLF CRLF), then parses status + headers. Any body bytes that - /// landed in the same read are returned as `bodyTail`. - private func readHeaders(fd: Int32) throws -> HeaderParseResult { - var buf = Data() - let chunk = 4096 - var scratch = [UInt8](repeating: 0, count: chunk) - let terminator: [UInt8] = [0x0d, 0x0a, 0x0d, 0x0a] // \r\n\r\n - let maxHeaderBytes = 64 * 1024 - - while true { - // Bound-check: a runaway server should not be able to make us - // allocate megabytes of header. - if buf.count > maxHeaderBytes { - throw URLError(.badServerResponse, - userInfo: [NSLocalizedDescriptionKey: "Header section too large"]) - } - let n = Darwin.read(fd, &scratch, chunk) - if n > 0 { - buf.append(scratch, count: n) - if let endIndex = findTerminator(in: buf, terminator: terminator) { - let headerData = buf[.. Int? { - guard data.count >= terminator.count else { return nil } - return data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Int? in - let bytes = ptr.bindMemory(to: UInt8.self) - outer: for i in 0...(data.count - terminator.count) { - for j in 0.. (Int, [String: String]) { - guard let raw = String(data: headerData, encoding: .isoLatin1) else { - throw URLError(.badServerResponse, - userInfo: [NSLocalizedDescriptionKey: "Header bytes not parseable"]) - } - // Split on CRLF; tolerate bare LF for safety. - let lines = raw.split(whereSeparator: { $0 == "\r" || $0 == "\n" }) - .map { String($0) } - .filter { !$0.isEmpty } - guard let statusLine = lines.first else { - throw URLError(.badServerResponse, - userInfo: [NSLocalizedDescriptionKey: "Empty header section"]) - } - let parts = statusLine.split(separator: " ", maxSplits: 2) - guard parts.count >= 2, let status = Int(parts[1]) else { - throw URLError(.badServerResponse, - userInfo: [NSLocalizedDescriptionKey: "Malformed status line: \(statusLine)"]) - } - var headers: [String: String] = [:] - for line in lines.dropFirst() { - guard let colon = line.firstIndex(of: ":") else { continue } - let key = line[.. String? { - switch ext.lowercased() { - case "jpg", "jpeg": return "image/jpeg" - case "png": return "image/png" - case "gif": return "image/gif" - case "webp": return "image/webp" - case "svg": return "image/svg+xml" - case "heic": return "image/heic" - default: return nil - } - } } From 061287c1635641ab66841020758fbbf38f9719ce Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 18:58:19 +0100 Subject: [PATCH 6/7] fix(ios): import ExpoModulesCore so ComapeoCore-Swift.h compiles in .mm `ComapeoCore-Swift.h` (auto-generated) re-exposes every @objc Swift class in the module to Obj-C, including `AppLifecycleDelegate` which extends `BaseExpoAppDelegateSubscriber` from `ExpoModulesCore`. Without that parent class in scope, Clang errored at: error: cannot find interface declaration for 'EXBaseAppDelegateSubscriber', superclass of 'AppLifecycleDelegate' error: no type or protocol named 'EXAppDelegateSubscriberProtocol' `@import ExpoModulesCore;` brings the parent types in before the bridging-header import. Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/ComapeoMediaImageLoader.mm | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ios/ComapeoMediaImageLoader.mm b/ios/ComapeoMediaImageLoader.mm index 68be93c..512c923 100644 --- a/ios/ComapeoMediaImageLoader.mm +++ b/ios/ComapeoMediaImageLoader.mm @@ -21,6 +21,15 @@ #import #import +// `ComapeoCore-Swift.h` (auto-generated below) re-exposes every `@objc` +// Swift class in this module to Obj-C, including `AppLifecycleDelegate` +// which extends `BaseExpoAppDelegateSubscriber` from `ExpoModulesCore`. +// Without ExpoModulesCore in scope, the generated header references +// `EXBaseAppDelegateSubscriber` / `EXAppDelegateSubscriberProtocol` +// without a definition and the build fails. Module-import +// (`@import ExpoModulesCore;`) keeps the dependency self-documenting. +@import ExpoModulesCore; + // The auto-generated Swift bridging header lands in different paths // depending on whether CocoaPods built ComapeoCore as a framework or a // static library; cover both. `__has_include` is a Clang preprocessor From b63e3bfcff7dacf4b627cddde24c090444fb7be4 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 29 Apr 2026 19:18:30 +0100 Subject: [PATCH 7/7] fix(ios): forward-declare ComapeoMediaFetcher instead of importing ComapeoCore-Swift.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@import ExpoModulesCore` was rejected by this Pod's compile flags ("use of '@import' when C++ modules are disabled"), and the previous plain `#import "ComapeoCore-Swift.h"` dragged in the auto-exposed `AppLifecycleDelegate` declaration whose superclass (Expo's `BaseExpoAppDelegateSubscriber`) isn't a public header type — Clang errored with "cannot find interface declaration for EXBaseAppDelegateSubscriber". Skip the bridging header entirely. Forward-declare only the `ComapeoMediaFetcher` class with the two class methods this file actually calls. Swift's `@objc` emits matching runtime symbols, so linking is unaffected; the compile-time surface stays minimal so unrelated Swift class exposures can't break the build. Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/ComapeoMediaImageLoader.mm | 40 ++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/ios/ComapeoMediaImageLoader.mm b/ios/ComapeoMediaImageLoader.mm index 512c923..427be28 100644 --- a/ios/ComapeoMediaImageLoader.mm +++ b/ios/ComapeoMediaImageLoader.mm @@ -21,25 +21,27 @@ #import #import -// `ComapeoCore-Swift.h` (auto-generated below) re-exposes every `@objc` -// Swift class in this module to Obj-C, including `AppLifecycleDelegate` -// which extends `BaseExpoAppDelegateSubscriber` from `ExpoModulesCore`. -// Without ExpoModulesCore in scope, the generated header references -// `EXBaseAppDelegateSubscriber` / `EXAppDelegateSubscriberProtocol` -// without a definition and the build fails. Module-import -// (`@import ExpoModulesCore;`) keeps the dependency self-documenting. -@import ExpoModulesCore; - -// The auto-generated Swift bridging header lands in different paths -// depending on whether CocoaPods built ComapeoCore as a framework or a -// static library; cover both. `__has_include` is a Clang preprocessor -// check that resolves at compile time without erroring on the unmet -// branch. -#if __has_include() -#import -#elif __has_include("ComapeoCore-Swift.h") -#import "ComapeoCore-Swift.h" -#endif +// Forward declaration for the @objc-renamed Swift class +// (`@objc(ComapeoMediaFetcher) public final class MediaFetcher`). +// +// We deliberately do NOT `#import "ComapeoCore-Swift.h"`. The generated +// header re-exposes EVERY `@objc` Swift class in the module, including +// `AppLifecycleDelegate`, which extends `BaseExpoAppDelegateSubscriber` +// from `ExpoModulesCore`. Importing the bridging header drags in those +// transitive Expo types, which aren't part of this Pod's public header +// surface — Clang errors with "cannot find interface declaration for +// EXBaseAppDelegateSubscriber". +// +// `@import ExpoModulesCore;` would fix it but is rejected by this Pod's +// compile flags ("use of '@import' when C++ modules are disabled"). A +// forward declaration of just the symbol we call into is the smallest +// surface that satisfies the compiler. Linking is fine: Swift's @objc +// emits the methods at the runtime names we declare here. +@interface ComapeoMediaFetcher : NSObject ++ (BOOL)canHandle:(nonnull NSURL *)url; ++ (void)fetchURL:(nonnull NSURL *)url + completion:(void (^_Nonnull)(NSData * _Nullable, NSError * _Nullable))completion; +@end @interface ComapeoMediaImageLoader : NSObject @end