DEV Community

CinfiniteDev
CinfiniteDev

Posted on

On iOS, You Can't Tell a Wall From a Bug

A story about a tiny PWA, a missing Apple feature, and the quiet war between JavaScript and iOS Safari.


The feature Apple forgot

There's a gesture that almost every modern phone can do in almost every photo app: put two fingers on a picture, twist, and watch it rotate under your fingertips. Instagram has it. Snapseed has it. Even Google Photos has it.

Apple's Photos app has never had it. Not in iOS 13, not in 18, not in 26. Apple gives you a 90-degree button and a straighten dial — a precise tool for fixing a crooked horizon, and nothing at all for the fun of just turning a picture. People have been asking for it for a decade. Apple's answer, year after year, is silence.

So, like every reasonable person does when a giant platform ignores a need, I decided to build the missing feature myself. Not as a native app — no App Store, no review process, no $99/year — but as a Progressive Web App. A website that installs like an app, works offline, and lives in a folder on your phone like a real citizen.

It took eleven build versions, six distinct iOS bugs, and one genuine discovery about how Safari handles images. This is that story.


Act I: The idea

The concept was simple, and it was never supposed to be complicated:

  1. Tap a button, pick photos from your library.
  2. They show up in a gallery.
  3. Tap one — it opens in an editor where you can rotate it with two fingers, pinch to zoom, and crop.
  4. Save it, and it goes back into your Photos library.

A gallery app with one killer gesture. The gesture part is straightforward — every touchscreen platform has pointer events, and rotating two points around a center is high school trigonometry. The gallery part seemed straightforward too. That was my first mistake.

The gallery part is where iOS makes it clear who you're dealing with.

Act II: The walls

A web page cannot see your photos. Not the originals, not a list of them, not even their thumbnails. On iOS, the only door into the Photos library is the system picker — the grid of images that pops up when you tap an upload button. It's a one-way door: photos come out, nothing goes in, and nothing is remembered. No API exists to enumerate the library, no permission model, no persistent access. Web content gets exactly one path into your camera roll, and it's a vending machine, not a library card.

So the gallery was born with a compromise: you import once, and the app keeps its own copies. Small ones. A 1600-pixel working copy and a 320-pixel thumbnail for every photo, stored in IndexedDB, the browser's built-in database. Ten thousand photos would fit in a few gigabytes. The originals stay in the Photos app; the app gets a taste of everything you feed it.

Import worked. The gallery worked. The editor worked — in the headless test browser, anyway, in a sterile automation environment where nothing ever misbehaves and every API does what the spec says.

Then I opened the app on a real iPhone. The error was short, and it became a recurring character in this story:

0 added, 1 failed
Enter fullscreen mode Exit fullscreen mode

Why a PWA at all?

Fair question, and it deserves a direct answer, because by the end of this act you already know the web's limits on iOS — and yet the decision to build on the web was deliberate, not naive.

Honestly? Because the interesting question isn't "what's the best tool for this feature." The interesting question is "what can the web actually do?" Every year the web platform grows — WebGPU, WASM, file systems, gestures, offline storage — and every year the honest answer to "can a browser app do this?" is "let's find out." A photo editor with finger gestures is a perfect experiment: it needs touch input, image decoding, local persistence, and OS integration, all at once. If a web page can do that, the web can do a lot. This project was never supposed to be a product. It's a probe.

The experiments it runs are the kind you can only run by building something real:

  • Can a browser app decode Apple's own camera format? (Yes — if you load a WASM decoder as a fallback chain.)
  • Can it store a photo library? (Yes — if you learn IndexedDB's personality.)
  • Can it speak to the OS, not just the browser? (Yes — through the share sheet, the one door the web is allowed to use.)
  • Can a gesture feel as good on a real screen as it does in a spec? (Try it — it snaps to 90 degrees when you lift your fingers.)

Each answer is a small finding about the platform, and each finding is the kind of thing you can only discover by shipping to a real device and reading the error messages the platform sends back. Some of those messages turned out to be policy — deliberate walls, to be planned around. The rest turned out to be rot — bugs years old, to be engineered around. The hard part was telling the two apart.

And the honest footnote: as a product, the web version has friction — one-by-one import, share-sheet save — that a native app wouldn't have. But this was never about the product. It's about the question. The best part is that the experiment succeeded: a browser, running on Apple's most locked-down platform, now rotates photos with two fingers and hands them back to the Photos library. That wasn't guaranteed. Now it's known.

Act III: The saga of the failures

The first suspect was HEIC — Apple's own image format, the default for every iPhone photo. It's not a browser-native format, so the decoder chain had to be rebuilt: try the browser's native decode, fall back to an <img> tag, and finally fire up a WASM decoder (libheif) that re-encodes HEIC to JPEG. That fix produced a proper error message. Progress, of a kind.

IDB transaction failed [image/jpeg, IMG_1234.jpg, 2345678 bytes]
Enter fullscreen mode Exit fullscreen mode

The decode had worked. The failure was in writing the photo to the database. A transaction that shouldn't fail, failing with no reason at all.

This was the moment the project became a real investigation. The pattern that emerged from hours of testing: on iOS Safari, a Blob — JavaScript's file object — is not what it seems. When the browser hands you a file from the photo picker, or when you ask a canvas to produce one, you get a reference to bytes that may not exist yet. The data is lazy. And Safari's IndexedDB, when asked to store that laziness, gives up and silently kills the transaction.

The fix was philosophical: stop trusting blobs, store raw bytes. Convert everything to an ArrayBuffer — an eager, in-memory copy — before touching the database. Import worked. The gallery filled with photos. For the first time, the whole pipeline ran on a real device.

Then Save broke.

async error : an error occurred reading the blob argument to createImageBitmap
Enter fullscreen mode Exit fullscreen mode

The same laziness, one level deeper. A blob produced by canvas.toBlob() couldn't be read back — not by the image decoder, not by an <img> tag, not even by arrayBuffer(). On iOS, canvas-produced files are, apparently, built from smoke. The workaround was to abandon blob reads entirely: compute the bytes synchronously from a toDataURL() string, pass raw bytes through every layer, and draw the thumbnail from the export canvas instead of decoding the export file. No decoder ever touched a canvas blob again.

And then the error became philosophical too:

async error : load failed
Enter fullscreen mode Exit fullscreen mode

The fetch() call that downloaded the base64 fallback failed. On iOS Safari, even fetch of a data URL can fail. The final fallback — converting base64 by hand, byte by byte, in pure JavaScript — cannot fail. It's just memory. That was the last error I ever chased in the save path.

There were more. An error that turned out to mean the user's build was cached in a service worker. An error that turned out to be a Vite host-allowlist rejecting the tunnel's domain. Eleven build versions, and each one taught a new lesson about the difference between a spec and a shipping browser.

Act IV: The HTTPS discovery

The last mystery was the strangest: Save worked, in the sense that a file was produced — but it went to the Downloads folder instead of the Photos library. There is no web API that writes into the Photos library. That is not a bug, it's a wall. The only door in is the share sheet — the system menu of apps you get when you tap Share. So I wired Save to hand the file to the OS via the Web Share API and let the user tap "Save Image," the Photos app's own action, through Apple's own door. It's the one way in, and it works.

But on the phone, the share sheet didn't appear. The diagnostic banner reported it plainly:

canShare(files)=false, standalone=false
Enter fullscreen mode Exit fullscreen mode

Not a crash. A refusal. And the reason was invisible until I looked at the URL: http. Plain, unencrypted HTTP. The Web Share API — like service workers, like everything interesting — only exists on HTTPS. The share sheet wasn't broken; it didn't exist. The LAN preview server my phone was pointed at was too insecure to borrow the system's share menu. An HTTPS tunnel later, the sheet appeared on the first tap, and the edited photo landed in the Photos library.

That moment — a JPEG, rotated by two fingers in a browser, walking back into Apple's protected photo library — is the whole app in one sentence.

Act V: What was learned

Eleven versions of a three-screen app, and the takeaways were larger than the app:

1. iOS Safari is a unique adversary. It's the only browser that misbehaves with lazy blobs, the only one whose IndexedDB aborts writes it should accept, the only one where fetch fails on a data URL. Almost every bug I fixed was a bug Apple hasn't fixed in years. WebKit's own engineers, asked about the picker methods of a modern file API, replied that they don't think they're "a good idea" , (due to security concerns) (source: WebKit Bugzilla #231706, comment 4). Apple ships a browser whose every storage API is a negotiation with the developer.

2. On iOS, you can't tell a wall from a bug. No library enumeration. No Select All in the picker. No direct save to Photos. No persistent file handles. Some of this is real privacy architecture: Apple's stated position is that web content should never touch your photos except through a visible system dialog. Defensible. But the rest of what I hit was just broken — IndexedDB transactions aborting for no reason, canvas blobs that can't be read back, fetch failing on a data URL. No policy rationale explains those. They're bugs, years old, unfixed. The wall is a plan; the bug is a secret — and on iOS you can't tell which one you've hit. Every failure is a guessing game between "Apple said no" and "Apple hasn't looked at this in years." The most useful skill this project built was telling the deliberate walls from the decaying ones: the policy you plan around, and the rot you engineer around. Nothing about that distinction is documented. You earn it one failed transaction at a time.

3. The web ceiling is real. A native app could read the library directly, reference originals without copying, and save without a share sheet. The PWA does everything the web allows — and the web allows noticeably less than iOS does. The point of the experiment was never to match native; it was to map how far the web goes before it stops. Now the map exists.

4. And yet. There is a photo on an iPhone right now, rotated to exactly the right angle with two fingers, that the native Photos app — the most-installed photo app on Earth — could never have produced. It was made by a few thousand lines of JavaScript, served from a free tunnel, running in a browser that fought every step of the way. The feature Apple forgot exists now. It just doesn't live where Apple wanted it to.


Touch-Gallery(https://touch-gallery.vercel.app) — a PWA that lets you rotate photos with your fingers, save them back to your Photos library, and never pay a subscription for it. If Apple ever ships this feature natively, it will be because someone made it look obvious first.

Top comments (0)