Skip to content

feat: serve blobs/icons over a Unix domain socket with native media bridges#193

Open
gmaclennan wants to merge 4 commits into
mainfrom
claude/blobs-icons-uds-serving-cehnc7
Open

feat: serve blobs/icons over a Unix domain socket with native media bridges#193
gmaclennan wants to merge 4 commits into
mainfrom
claude/blobs-icons-uds-serving-cehnc7

Conversation

@gmaclennan

Copy link
Copy Markdown
Member

Rework of the PR #44 approach on top of current main. The backend's
blob/icon Fastify server previously bound to 127.0.0.1:, so any
app on the device could read media URLs and share-sheet flows had no
usable URL form. It now binds to a third Unix domain socket
(media.sock, alongside comapeo.sock / control.sock) inside the app
sandbox, and per-platform native bridges serve the bytes to the app.

@comapeo/core keeps zero knowledge of URLs or how media is served: a
one-line patch-package patch makes getFastifyServerAddress return an
empty base for UDS binds, so $blobs.getUrl() / $icons.getIconUrl()
return relative paths (/blobs/...). The RN side owns URL composition:

  • getMediaBaseUrl() / toMediaUrl(): compose the platform-native in-app
    URL (Android content://.comapeo.media, iOS
    comapeo://media). Host apps or @comapeo/core-react append these to
    data records in the frontend.
  • getShareableMediaUrl(): snapshots a blob to a cache file (atomic
    rename, Content-Type-derived extension, 24h pruning) and returns a
    file:// URL for the Android/iOS share sheets — in-app media URLs
    cannot cross the process boundary.

Native serving:

  • Android: MediaContentProvider streams HTTP/1.0-over-UDS responses
    through a ParcelFileDescriptor pipe (bounded thread pool, buffered
    header parse, error propagation via closeWithError, Content-Length
    truncation detection).
  • iOS: MediaFetcher (shared UDS fetch pipeline) backs
    ComapeoMediaImageLoader (RCTImageURLLoader for RN ) and a
    globally-registered streaming MediaURLProtocol (URLSession.shared
    consumers).

Tests at four layers, all black-box against the wire/API contract:

  • backend/lib/media-serving.test.mjs boots the real backend entry over
    real sockets: init handshake, blob create via RPC, relative getUrl,
    raw HTTP/1.0 GET over media.sock returns the stored bytes.
  • ios/Tests/MediaFetcherTests.swift exercises fetch/stream/share paths
    against an in-test UDS HTTP server (truncation, 404, cancellation).
  • android MediaContentProviderTest drives the provider through
    ContentResolver against a fake UDS backend; MediaHttpClientTest
    covers the shared MIME-extension mapping on the JVM.
  • apps/e2e media suite: blob → relative URL → native URL →
    Image.getSize decodes through the real platform pipeline, plus the
    shareable file:// snapshot.

BREAKING CHANGE: $blobs.getUrl() / $icons.getIconUrl() now return
relative paths; compose them with getMediaBaseUrl() (or toMediaUrl())
before handing them to or other consumers.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01LkeJPhPnipj9JNwYXsD6gU

…ridges

Rework of the PR #44 approach on top of current main. The backend's
blob/icon Fastify server previously bound to 127.0.0.1:<random>, so any
app on the device could read media URLs and share-sheet flows had no
usable URL form. It now binds to a third Unix domain socket
(media.sock, alongside comapeo.sock / control.sock) inside the app
sandbox, and per-platform native bridges serve the bytes to the app.

@comapeo/core keeps zero knowledge of URLs or how media is served: a
one-line patch-package patch makes getFastifyServerAddress return an
empty base for UDS binds, so $blobs.getUrl() / $icons.getIconUrl()
return *relative* paths (/blobs/...). The RN side owns URL composition:

- getMediaBaseUrl() / toMediaUrl(): compose the platform-native in-app
  URL (Android content://<applicationId>.comapeo.media, iOS
  comapeo://media). Host apps or @comapeo/core-react append these to
  data records in the frontend.
- getShareableMediaUrl(): snapshots a blob to a cache file (atomic
  rename, Content-Type-derived extension, 24h pruning) and returns a
  file:// URL for the Android/iOS share sheets — in-app media URLs
  cannot cross the process boundary.

Native serving:
- Android: MediaContentProvider streams HTTP/1.0-over-UDS responses
  through a ParcelFileDescriptor pipe (bounded thread pool, buffered
  header parse, error propagation via closeWithError, Content-Length
  truncation detection).
- iOS: MediaFetcher (shared UDS fetch pipeline) backs
  ComapeoMediaImageLoader (RCTImageURLLoader for RN <Image>) and a
  globally-registered streaming MediaURLProtocol (URLSession.shared
  consumers).

Tests at four layers, all black-box against the wire/API contract:
- backend/lib/media-serving.test.mjs boots the real backend entry over
  real sockets: init handshake, blob create via RPC, relative getUrl,
  raw HTTP/1.0 GET over media.sock returns the stored bytes.
- ios/Tests/MediaFetcherTests.swift exercises fetch/stream/share paths
  against an in-test UDS HTTP server (truncation, 404, cancellation).
- android MediaContentProviderTest drives the provider through
  ContentResolver against a fake UDS backend; MediaHttpClientTest
  covers the shared MIME-extension mapping on the JVM.
- apps/e2e media suite: blob → relative URL → native URL →
  Image.getSize decodes through the real platform pipeline, plus the
  shareable file:// snapshot.

BREAKING CHANGE: $blobs.getUrl() / $icons.getIconUrl() now return
relative paths; compose them with getMediaBaseUrl() (or toMediaUrl())
before handing them to <Image> or other consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkeJPhPnipj9JNwYXsD6gU
@github-actions github-actions Bot added breaking Breaking change (changelog) feature New feature (changelog) labels Jul 3, 2026
claude and others added 3 commits July 3, 2026 17:31
…napshot

Cache-file share snapshots were observed being evicted on low-storage
devices before the share completed. On Android the snapshot is
unnecessary: the MediaContentProvider already streams from the
UDS-bound media server, the socket is served by the :ComapeoCore
foreground service (which outlives app switches), and
grantUriPermissions was already in place — so getShareableMediaUrl now
returns the same content:// URI the app renders with. Zero copy,
nothing on disk to evict. It HEAD-validates the path first so a missing
blob rejects at the call site rather than inside the receiving app.

Share receivers resolve metadata through the ContentResolver, not HTTP,
so the provider now answers those from the served headers:
- getType() returns the real Content-Type (header cache warmed by every
  open/HEAD, else a bounded 2-attempt HEAD over the UDS, degrading to
  application/octet-stream when the backend is down — metadata calls
  run on other apps' binder threads and must not block on the
  boot-covering retry budget).
- query() serves OpenableColumns.DISPLAY_NAME (path segment plus an
  extension derived from the MIME type). SIZE is null: the backend
  streams without Content-Length (pinned by the backend e2e test; if
  upstream core adds the header, SIZE and the existing truncation
  checks both light up).

iOS keeps the file snapshot (share extensions run out-of-process where
comapeo:// means nothing) but moves it from the purgeable Caches
directory to Application Support (excluded from backup, still pruned
after 24h) so the same eviction failure mode can't hit it.

Known Android trade-offs, documented: receivers that defer reading
until after the backend stops fail, as do receivers requiring a
seekable descriptor; the share Intent must carry
FLAG_GRANT_READ_URI_PERMISSION (react-native-share does; expo-sharing
does not support content://).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkeJPhPnipj9JNwYXsD6gU
Swift folds a "\r\n" pair into one grapheme-cluster Character, so the
Character-level split(whereSeparator:) never matched the line breaks and
returned the whole header block as a single line. The status line still
parsed (Int() reads the code before the CRLF), which masked the bug for
status but dropped every subsequent header — Content-Type and
Content-Length were lost, breaking MIME detection, shareable-file
extensions, and truncated-body detection. Split on unicode scalars, where
\r and \n are distinct.
…s-serving-cehnc7

* origin/main:
  fix(scripts): fail the unit-test runner when the Gradle test task fails
  ci(deps): bump actions/cache in the actions group across 1 directory
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change (changelog) feature New feature (changelog)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants