Foldic for iPhone was approved today. We submitted it on July 25 — eleven days in the queue.
On the day it went in, that app had only ever run on a simulator and through a few short sessions on a real device. During the eleven days of waiting, I put it on an actual iPhone, plugged in an actual USB drive, and pointed it at a real library: 46 albums, 17,351 photos. That is when I found out how much of the version I had already shipped was wrong.
Here is what those eleven days changed. None of it is in the 0.4.0 that went live today — that build is who we were ten days ago.
Don't decide to scan on the user's behalf
The first version scanned the whole library the moment you opened it.
On a Mac that decision is harmless: local disk, a few seconds. On an iPhone with a USB drive attached it is a different thing entirely — every already-backed-up file on the drive has to be enumerated, which takes minutes across 46 albums, and the user may have opened the app just to look at it. Worse, there was no way to stop: you waited it out or you force-quit.
Now opening the app does one cheap thing: it lists your albums (using PhotoKit's estimated counts, no membership reads) in about a second. To scan, you tap Scan All at the bottom. Every album row also has its own Scan — to check one album you no longer scan the library.
The 30-minute timer and full-library change events are quiet on iPhone too. On this device, scanning happens when you ask for it.
When you press cancel, it has to actually stop
So I added cancellation. The first implementation checked two places: between albums, and inside the loop that walks the destination drive — the slowest part of a scan on USB storage. It tested well.
Then someone who actually uses it reported: I pressed cancel and that album kept scanning.
The place I had missed was the third one, and it was the longest: asking PhotoKit for an album's membership. That enumeration looks up the resources of every single photo, which runs for minutes on a large album — and it sat between my two checkpoints. The flag was being set, and the scan did respect it; it just had to wait for that entire enumeration to finish before anything looked at it.
The native enumeration now checks the flag every 100 photos and between albums. Cancel lands in a second or two.
The incidental lesson mattered more than the cancellation: data from an abandoned pass must never be written down. A truncated file listing, if you use it for counts, produces the confident conclusion "this album is missing 8,000 files" — and the next sync acts on that conclusion. So an interrupted album is discarded whole, as if this pass never reached it. Its previous record stays intact.
The screen was on for three hours
Syncing on iPhone is foreground-only — there is no reliable access to external storage in the background. So while a sync runs we have to keep the screen from sleeping, or iOS locks the device, freezes the process, and the backup stops halfway.
That is correct, and it has a cost: a sync of ten thousand photos runs for hours, and the screen burns at whatever brightness you set for those hours.
There is now a power-saving screen (on by default). Thirty seconds without a touch while work runs, and the backlight fades to minimum over half a second while the display switches to a black screen with progress in near-black text. Black pixels on OLED emit nothing; with the backlight at minimum that is about as little power as this device can draw while still working. Any touch restores your exact brightness.
UIScreen.brightness is system state — change it and it does not come back on its own, not even if the app is killed. So restoration is wired to four paths: the user's touch, work finishing, the app resigning active (a native notification observer), and page visibility. When a feature reaches for something that isn't ours, the recovery path deserves more care than the happy path.
The bug the power-saving screen caught
The day after that feature shipped I hit something contradictory: the numbers on the power-saving screen were advancing while the normal interface looked frozen.
Both read the same data. The power-saving screen only puts it somewhere else.
The cause was layout. The progress row is a horizontal flex row, and its title — "[45 queued] Syncing 'All Photos'" — is set not to wrap. In CSS, text that cannot wrap also cannot shrink below its own width, so on a narrow phone the title claimed the whole row and pushed the counts, the one thing the user actually watches, outside the viewport. The numbers had been advancing the whole time, just past the right edge of the screen.
On a phone, invisible is indistinguishable from broken. The user's conclusion — "it's stuck" — was entirely correct given the information available to them.
The fix is small: let the title ellipsize, pin the counts where they can be seen. The interesting part is that a feature built to save battery became the diagnostic for a different bug, purely because it put the same data in a different place.
Silence is not the same as dead
The same investigation exposed something else: our diagnostic log wrote nothing at all between "queued" and "done".
"All Photos" holds 12,372 photos, 239 of them already backed up. The remaining twelve thousand each have to be pulled from iCloud as originals and written to the USB drive — that is hours of work. And for those hours the log was silent, which is indistinguishable from a hang.
It now writes one progress line a minute. One line — but it turns "working" and "wedged" into two different things.
And the files you cannot see
A user (me) got a wall of red text after a sync: 619 files moved to the Trash, 2 failed.
Those were AppleDouble files — the ._ twins macOS writes on exFAT/FAT drives when the filesystem cannot hold extended attributes. They carry no photo data, but our file-identity logic was treating them as photos: the tail of a ._ name happens to satisfy the same identifier pattern, so a lone sidecar could convince Foldic that a photo was still present.
Sidecars are now bound to their data file: alive while the file is, orphaned once it is gone (the file-name migration leaves these behind), and removed together when the file itself leaves as an orphan. A failed move to the Trash is retried once — those 2 failures were iOS's external-storage helper dropping requests under load, and a retry clears them. Whatever still fails is summarised in one quiet line instead of dumping two full container paths into the user's face.
Writing the lessons down
On a phone, invisible is broken. A desktop layout can hide its sins in wide margins. A phone cannot.
Cancellation belongs in the longest loop, not the most obvious one. Both of my checkpoints were in sensible places. Between them sat work longer than either.
One feature can be another feature's diagnostic — because it presents the same data differently.
Long work has to keep making noise. Hours of silence reads as death, to users and to the developer reading the log.
These fixes are in TestFlight now and go out with the next submission. The iPhone version on the App Store today, 0.4.0, is that ten-day-old snapshot — it works, but none of the above is in it yet.
提出した時点で、そのアプリはシミュレータと数回の短い実機テストしか経験していませんでした。待っている11日間で、私は実機の iPhone に入れ、実際の USB ドライブを挿し、本物のライブラリ——46アルバム、17,351枚——に向けて動かしました。そこで、すでに提出済みのバージョンがどれだけ間違っていたかを知ることになります。
Mac ではこの判断は無害です。ローカルディスク、数秒の話。しかし USB ドライブを挿した iPhone では話がまったく違います。ドライブ上のバックアップ済みファイルをすべて列挙する必要があり、46アルバムでは数分かかります。ユーザーはただ様子を見たかっただけかもしれません。さらに悪いのは、止める手段がなかったこと。待ちきるか、強制終了するかの二択でした。
Foldic iPhone 버전이 오늘 승인되었습니다. 제출은 7월 25일 — 심사에 11일이 걸렸습니다.
제출한 날, 그 앱은 시뮬레이터와 몇 번의 짧은 실기기 테스트만 경험한 상태였습니다. 기다리는 11일 동안 저는 실제 iPhone에 넣고, 실제 USB 드라이브를 꽂고, 진짜 보관함 — 46개 앨범, 17,351장 — 을 향해 돌렸습니다. 그리고 이미 제출한 버전이 얼마나 잘못되어 있었는지 알게 되었습니다.
다음은 그 11일이 바꾼 것들입니다. 오늘 공개된 0.4.0에는 하나도 들어 있지 않습니다. 그 빌드는 열흘 전의 우리입니다.
스캔할지를 사용자 대신 결정하지 말 것
첫 버전은 열자마자 보관함 전체를 스캔했습니다.
Mac에서는 무해한 결정입니다. 로컬 디스크, 몇 초. 하지만 USB 드라이브를 연결한 iPhone에서는 완전히 다른 이야기입니다. 드라이브의 이미 백업된 파일을 모두 열거해야 하고, 46개 앨범이면 몇 분이 걸립니다. 사용자는 그냥 한번 열어보려던 것일 수도 있습니다. 더 나쁜 건 멈출 방법이 없었다는 점입니다. 끝날 때까지 기다리거나 강제 종료하는 것뿐이었습니다.
이제 앱을 열면 값싼 일 하나만 합니다. 앨범 목록 표시(PhotoKit의 추정 개수 사용, 멤버십은 읽지 않음)에 약 1초. 스캔하려면 아래의 '전체 스캔'을 누릅니다. 각 앨범 행에도 '스캔'이 있어서, 앨범 하나를 확인하려고 보관함 전체를 스캔할 필요가 없습니다.
30분 타이머와 보관함 전체 변경 이벤트도 iPhone에서는 조용합니다. 이 기기에서 스캔은 당신이 요청할 때 일어납니다.
취소를 누르면 정말로 멈춰야 한다
그래서 취소를 추가했습니다. 첫 구현은 두 곳을 확인했습니다. 앨범 사이, 그리고 대상 드라이브를 순회하는 루프 안 — USB 저장장치 스캔에서 가장 느린 부분입니다. 테스트는 잘 되었습니다.
그런데 실제로 쓰는 사람이 보고했습니다. 취소를 눌렀는데 그 앨범은 계속 스캔한다.
놓친 세 번째 장소가 가장 긴 작업이었습니다. PhotoKit에 앨범 멤버십을 요청하는 부분입니다. 이 열거는 사진 한 장마다 리소스를 조회하므로 큰 앨범에서는 몇 분이 걸립니다. 그리고 그것은 제 두 검사 지점 사이에 있었습니다. 플래그는 설정되었고 스캔도 그것을 존중했습니다. 다만 누군가 그것을 보기까지, 그 열거가 끝나기를 기다려야 했습니다.
이제 네이티브 열거는 사진 100장마다, 그리고 앨범이 바뀔 때마다 플래그를 확인합니다. 취소는 1~2초 안에 적용됩니다.
그리고 부수적으로 얻은 교훈이 취소 자체보다 중요했습니다. 중단된 작업의 데이터는 절대 기록해서는 안 된다. 잘린 파일 목록을 개수 계산에 쓰면 '이 앨범은 8,000개 파일이 없다'는 확신에 찬 결론이 나오고, 다음 동기화는 그 결론에 따라 움직입니다. 그래서 중단된 앨범은 통째로 버려지고, 이번 패스는 그것에 닿지 않은 것으로 취급됩니다. 이전 기록은 그대로 남습니다.
화면이 세 시간 동안 켜져 있었다
iPhone에서 동기화는 포그라운드 전용입니다. 백그라운드에서는 외부 저장장치에 안정적으로 접근할 수 없습니다. 그래서 동기화 중에는 화면이 잠들지 않게 해야 합니다. 그러지 않으면 iOS가 잠기고, 프로세스가 얼고, 백업은 중간에 멈춥니다.
이는 옳습니다. 다만 대가가 있습니다. 만 장 규모의 동기화는 몇 시간을 돌고, 그 시간 내내 화면은 당신이 설정한 밝기로 켜져 있습니다.
이제 절전 화면이 있습니다(기본 켜짐). 작업 중 30초간 터치가 없으면 백라이트가 0.5초에 걸쳐 최저 밝기로 떨어지고, 화면은 검은 배경에 거의 검은 글자로 진행 상황을 보여주는 것으로 바뀝니다. OLED의 검은 픽셀은 빛을 내지 않습니다. 최저 백라이트와 합치면, 작동하면서 쓸 수 있는 가장 적은 전력에 가깝습니다. 어디든 터치하면 원래 밝기로 즉시 돌아옵니다.
UIScreen.brightness는 시스템 상태입니다. 바꾸면 스스로 돌아오지 않습니다. 앱이 강제 종료되어도 돌아오지 않습니다. 그래서 복원을 네 경로에 연결했습니다. 사용자의 터치, 작업 완료, 앱의 비활성화(네이티브 알림 옵서버), 그리고 페이지 가시성 변화. 우리 것이 아닌 상태에 손을 뻗는 기능이라면, 복귀 경로가 정상 경로보다 더 정성을 들일 만합니다.
절전 화면이 잡아낸 버그
그 기능을 넣은 다음 날, 모순을 만났습니다. 절전 화면의 숫자는 올라가는데, 일반 화면은 멈춰 보였습니다.
둘은 같은 데이터를 읽습니다. 절전 화면은 그것을 다른 자리에 놓을 뿐입니다.
원인은 레이아웃이었습니다. 진행 줄은 가로 flex 레이아웃이고, 제목 — '[45개 대기 중] 'All Photos' 동기화 중' — 은 줄바꿈하지 않도록 설정되어 있습니다. CSS에서 줄바꿈할 수 없는 텍스트는 자기 너비보다 좁게 줄어들 수도 없습니다. 그래서 좁은 휴대폰 화면에서 제목이 줄 전체를 차지하고, 사용자가 실제로 보고 있는 개수를 화면 밖으로 밀어냈습니다. 숫자는 계속 올라가고 있었습니다. 화면 오른쪽 바깥에서.
휴대폰에서 보이지 않는 것은 고장난 것과 구별되지 않습니다. '멈췄다'는 사용자의 결론은, 그가 가진 정보 안에서는 완전히 옳았습니다.
수정은 작습니다. 제목을 줄임표로 자르고, 개수를 보이는 자리에 고정합니다. 흥미로운 점은 배터리를 아끼려고 만든 기능이 다른 버그의 진단 도구가 되었다는 것입니다 — 같은 데이터를 다른 곳에 두었다는 이유만으로.
침묵은 죽음과 같지 않다
같은 조사에서 또 하나가 드러났습니다. 진단 로그는 '대기열 추가'와 '완료' 사이에 아무것도 쓰지 않았습니다.
'All Photos'에는 12,372장이 있고 그중 239장은 이미 백업되어 있습니다. 남은 1만 2천여 장은 각각 iCloud에서 원본을 받아 USB 드라이브에 써야 합니다. 몇 시간의 작업입니다. 그리고 그 몇 시간 동안 로그는 침묵했습니다. 멈춤과 구별할 수 없습니다.
이제 1분마다 진행 상황을 한 줄씩 씁니다. 한 줄이지만, '작동 중'과 '죽음'을 서로 다른 것으로 만들어 줍니다.
그리고 보이지 않는 파일들
어떤 사용자(저입니다)가 동기화 후 붉은 글자의 벽을 봤습니다. 619개 파일을 휴지통으로, 2개 실패.
그것은 AppleDouble 파일 — exFAT/FAT 드라이브에서 확장 속성을 담을 수 없을 때 macOS가 쓰는 ._ 쌍둥이입니다. 사진 데이터는 없지만, 우리 파일 식별 로직은 그것을 사진으로 취급했습니다. ._ 이름의 끝부분이 우연히 같은 식별자 패턴을 만족하기 때문에, 홀로 남은 사이드카 하나로 '그 사진은 아직 있다'고 Foldic을 납득시킬 수 있었습니다.
이제 사이드카는 데이터 파일과 묶여 있습니다. 파일이 있는 동안은 살아 있고, 사라지면 고아가 되며(파일 이름 마이그레이션이 이런 것들을 남깁니다), 파일 자신이 고아로 떠날 때 함께 떠납니다. 휴지통 이동 실패는 한 번 재시도합니다 — 그 2개는 iOS의 외부 저장장치 헬퍼가 고부하에서 요청을 놓친 것이고, 재시도로 해결되었습니다. 그래도 실패한 것은 컨테이너 전체 경로를 사용자 얼굴에 들이붓는 대신 조용한 한 줄로 요약됩니다.
교훈을 적어 두기
휴대폰에서 보이지 않는 것은 고장난 것이다. 데스크톱 레이아웃은 넓은 여백에 죄를 숨길 수 있습니다. 휴대폰에는 그 여백이 없습니다.
취소는 가장 눈에 띄는 루프가 아니라 가장 긴 루프에 넣는다. 제 두 검사 지점은 모두 합리적인 곳에 있었습니다. 그 사이에 둘보다 긴 작업이 끼어 있었습니다.
한 기능은 다른 기능의 진단 도구가 될 수 있다 — 같은 데이터를 다르게 보여주기 때문입니다.
긴 작업은 계속 소리를 내야 한다. 몇 시간의 침묵은 사용자에게도, 로그를 읽는 개발자에게도 죽음처럼 보입니다.
이 수정들은 이미 TestFlight에 있고 다음 제출과 함께 나갑니다. 오늘 App Store에 공개된 iPhone 버전 0.4.0은 그 열흘 전의 스냅샷입니다. 작동하지만, 위의 어느 것도 아직 들어 있지 않습니다.