diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 0f24b0b..7dac406 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -64,5 +64,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 f09e304..597c57e 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
@@ -201,5 +202,15 @@ class ComapeoCoreModule : Module() {
// sentinel-check.
synchronized(stateLock) { lastError }
}
+
+ // 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 d93a759..dcc415e 100644
--- a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt
+++ b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt
@@ -32,6 +32,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 5228437..7fe2691 100644
--- a/android/src/main/java/com/comapeo/core/NodeJSService.kt
+++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt
@@ -119,6 +119,7 @@ class NodeJSService(
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()
@@ -270,6 +271,7 @@ class NodeJSService(
comapeoSocketFile.absolutePath,
controlSocketFile.absolutePath,
dataDir,
+ mediaSocketFile.absolutePath,
)
)
log("NodeJS service completed with exit code $exitCode")
@@ -392,6 +394,7 @@ class NodeJSService(
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/apps/example/App.tsx b/apps/example/App.tsx
index d080f99..3c3b387 100644
--- a/apps/example/App.tsx
+++ b/apps/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/apps/example/package-lock.json b/apps/example/package-lock.json
index 32ba40d..111ed44 100644
--- a/apps/example/package-lock.json
+++ b/apps/example/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"expo": "55.0.17",
+ "expo-asset": "55.0.16",
"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",
@@ -1327,6 +1329,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 +2370,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2868,6 +2872,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3417,6 +3422,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 +3472,35 @@
}
}
},
+ "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-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-modules-autolinking": {
"version": "55.0.18",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-55.0.18.tgz",
@@ -3505,6 +3540,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"
}
@@ -3807,34 +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",
- "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",
@@ -3850,6 +3858,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"
},
@@ -4765,9 +4774,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4788,9 +4794,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4811,9 +4814,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4834,9 +4834,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6003,6 +6000,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 +6181,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"
}
@@ -7002,6 +7001,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/apps/example/package.json b/apps/example/package.json
index 9a1fd72..487da4e 100644
--- a/apps/example/package.json
+++ b/apps/example/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"expo": "55.0.17",
+ "expo-asset": "55.0.16",
"react": "19.2.5",
"react-native": "0.83.6",
"react-native-safe-area-context": "5.6.2"
diff --git a/backend/index.js b/backend/index.js
index c15b0ee..94e0d95 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();
@@ -173,7 +185,17 @@ process.on("unhandledRejection", (reason) => {
throw Object.assign(e, { phase: "init" });
}
- // 3. Construct the manager and bind the comapeo RPC socket.
+ // 3. Construct the manager and bind the comapeo RPC + media sockets.
+ //
+ // The media socket carries blob/icon HTTP responses streamed by the
+ // Fastify plugins that `MapeoManager` registers (`BlobServerPlugin`,
+ // `IconServerPlugin`, …). Binding it AFTER `createComapeo` runs means
+ // the routes are wired before the socket starts accepting connections;
+ // binding to a UDS instead of a TCP port keeps the bytes inside the
+ // app sandbox so no other app on the device can read the URLs.
+ // Native side: `MediaContentProvider` (Android) and `MediaFetcher`
+ // (iOS, via `RCTImageURLLoader` and the global `URLProtocol`) are
+ // the only clients of this socket.
try {
comapeo = createComapeo({
privateStorageDir,
@@ -182,11 +204,16 @@ process.on("unhandledRejection", (reason) => {
rootKey,
});
comapeoRpcServer = new ComapeoRpcServer(comapeo);
- await comapeoRpcServer.listen(comapeoSocketPath);
+ await Promise.all([
+ comapeoRpcServer.listen(comapeoSocketPath),
+ fastify.listen({ path: mediaSocketPath }),
+ ]);
} catch (e) {
throw Object.assign(e, { phase: "construct" });
}
- console.log(`Comapeo socket listening on ${comapeoSocketPath}`);
+ console.log(
+ `Comapeo socket listening on ${comapeoSocketPath}, media socket on ${mediaSocketPath}`,
+ );
// 4. Announce ready. The settle-window-then-ready dance is gone now
// that `ready` carries actual meaning (manager exists, RPC is safe).
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 94b5b68..fbcb9bd 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",
@@ -430,7 +431,6 @@
"os": [
"aix"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -448,7 +448,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -466,7 +465,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -484,7 +482,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -502,7 +499,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -520,7 +516,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -538,7 +533,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -556,7 +550,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -574,7 +567,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -592,7 +584,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -610,7 +601,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -628,7 +618,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -646,7 +635,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -664,7 +652,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -682,7 +669,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -700,7 +686,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -718,7 +703,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -736,7 +720,6 @@
"os": [
"netbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -754,7 +737,6 @@
"os": [
"netbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -772,7 +754,6 @@
"os": [
"openbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -790,7 +771,6 @@
"os": [
"openbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -808,7 +788,6 @@
"os": [
"openharmony"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -826,7 +805,6 @@
"os": [
"sunos"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -844,7 +822,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -862,7 +839,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -880,7 +856,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -2363,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",
@@ -2550,6 +2526,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 +2734,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",
@@ -2962,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"
}
@@ -3049,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"
@@ -3128,6 +3123,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 +3273,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",
@@ -3394,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"
}
@@ -3445,6 +3470,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 +4413,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 +4452,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 +4509,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 +4536,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 +4690,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 +4907,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 +5075,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 +5244,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 +5266,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 +5440,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 +6016,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",
@@ -5907,6 +6187,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6427,12 +6708,73 @@
"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",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6856,6 +7198,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",
@@ -7244,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"
}
@@ -7270,6 +7623,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 +7824,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",
@@ -7531,6 +7910,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7571,6 +7951,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 +8198,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 0a53beb..008831a 100644
--- a/ios/AppLifecycleDelegate.swift
+++ b/ios/AppLifecycleDelegate.swift
@@ -16,7 +16,34 @@ 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 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 = {
+ MediaFetcher.socketPathProvider = {
+ 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`
@@ -178,6 +205,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 030b55d..cfdb485 100644
--- a/ios/ComapeoCoreModule.swift
+++ b/ios/ComapeoCoreModule.swift
@@ -99,5 +99,14 @@ public class ComapeoCoreModule: Module {
}
return ["errorPhase": info.phase, "errorMessage": info.message]
}
+
+ // 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` /
+ // `ComapeoMediaImageLoader`.
+ Function("getMediaContentAuthority") { () -> String in
+ return ""
+ }
}
}
diff --git a/ios/ComapeoMediaImageLoader.mm b/ios/ComapeoMediaImageLoader.mm
new file mode 100644
index 0000000..427be28
--- /dev/null
+++ b/ios/ComapeoMediaImageLoader.mm
@@ -0,0 +1,130 @@
+// 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
+
+// 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
+
+@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
new file mode 100644
index 0000000..b965730
--- /dev/null
+++ b/ios/MediaURLProtocol.swift
@@ -0,0 +1,158 @@
+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 {
+ 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 MediaFetcher.canHandle(url)
+ }
+
+ 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
+ }
+
+ let opened: MediaFetcher.OpenedResponse
+ do {
+ opened = try MediaFetcher.open(url: url)
+ } catch let error as URLError {
+ failClient(error)
+ return
+ } catch {
+ failClient(.init(.cannotConnectToHost,
+ userInfo: [NSUnderlyingErrorKey: error,
+ NSLocalizedDescriptionKey: error.localizedDescription]))
+ return
+ }
+
+ stateLock.lock()
+ // Honour stopLoading() that arrived during MediaFetcher.open().
+ guard !isCancelled else {
+ stateLock.unlock()
+ close(opened.fd)
+ return
+ }
+ fd = opened.fd
+ stateLock.unlock()
+
+ defer {
+ stateLock.lock()
+ let toClose = fd
+ fd = -1
+ stateLock.unlock()
+ if toClose >= 0 { close(toClose) }
+ }
+
+ guard (200..<300).contains(opened.status) else {
+ failClient(.init(.fileDoesNotExist,
+ userInfo: [NSLocalizedDescriptionKey:
+ "HTTP \(opened.status) for \(url.path)"]))
+ return
+ }
+
+ // 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 = opened.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 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(opened.fd, &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)
+ }
+}
diff --git a/ios/NodeJSService.swift b/ios/NodeJSService.swift
index 8b5687d..a4a883b 100644
--- a/ios/NodeJSService.swift
+++ b/ios/NodeJSService.swift
@@ -46,6 +46,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
@@ -56,6 +57,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()
@@ -143,13 +148,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,
@@ -398,9 +404,11 @@ class NodeJSService {
// ComapeoManager is constructed), driven by `handleControlMessage`.
// 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
@@ -419,6 +427,7 @@ class NodeJSService {
comapeoSocketPath,
controlSocketPath,
privateStorageDir,
+ mediaSocketPath,
]
let exitCode = nodeEntryPoint(args)
log("Node.js exited with code \(exitCode)")
@@ -462,6 +471,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 821c212..b9f865e 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 a2aff9a..48ceca5 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, state } 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}`);
+}