- Swift 95.8%
- Shell 4.2%
The screen sat at 177.8 of 178.1 MB, "1 waiting to send", "295 KB still to go", and looked stuck. Nothing was wrong: that file was on the server at VERIFYING_BACKUP, being checked into Google Photos. The server sends a resolution only for a terminal status, so it was telling the client nothing, and the client still held the row as declared. An idle engine cannot tell "the server wants these bytes" from "the server already has them and is still thinking", so it no longer claims to. A declared row with no task against it now reads as waiting on the server, and the tile says "not yet confirmed" rather than "left to send". The stale re-declare drops from ten minutes to two, which is the server's own resolution-retry cadence — past that, a row it has said nothing about is one it will not speak about unasked, and an idempotent declare returns whatever it holds now. Re-arming a pass now backs off when the pass changed nothing, 3s doubling to 96s. Otherwise one row the far side is still verifying keeps the phone polling every few seconds for as long as the screen stays on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| App | ||
| Scripts | ||
| Sources | ||
| Tests/GPBSyncCoreTests | ||
| .gitignore | ||
| Package.swift | ||
| project.yml | ||
| README.md | ||
GPB Sync for iOS
The third GPB Sync client. It does on an iPhone what the Android and macOS clients do on their devices: enumerate the photo library, hash what it finds, tell the server what it holds, and carry out the transfers it is assigned — over the LAN to another paired device when the server says so, and through the server's relay otherwise.
It is a port, not a rewrite. The architecture is written down in docs/SOURCE-CLIENTS.md in
the backend repository — the modules, which of them may know what platform they are on, and the
invariants that hold everywhere. That document exists because the wire protocol was always shared
and the client architecture was not, and the cost was concrete: the Android client shipped a source
that could not send over the LAN at all for months, because the half of the design that made it
possible on macOS had no counterpart there and nothing made its absence visible.
Read it before adding anything here.
Why this is a Swift package
The half of this client that carries the bugs — chunk arithmetic, resume, the selection rule, the declare/transfer state machine — imports nothing but Foundation. As a package it therefore builds and runs its tests on an ordinary Linux box, which is what the server is developed on:
swift build && swift test
The alternative is a codebase whose logic can only be exercised by whoever is holding the Mac. That
is not a theoretical concern — it is why Tests/typecheck-linux.sh had to be invented for the
macOS client, and this layout is strictly better than that workaround.
Sources/GPBSyncCore/ Foundation only. Builds and tests anywhere.
Sources/GPBSyncPhotos/ The PhotoKit seam. Every file guarded by `#if canImport(Photos)`,
so on Linux it compiles to an empty module and the core's tests
still run. The guard is what enforces the seam.
Tests/GPBSyncCoreTests/ XCTest. Runs on Linux.
The app target is added in Xcode and depends on these libraries. Anything needing PhotoKit, the
Keychain, Network.framework or SwiftUI lives behind a seam or in the app — never in GPBSyncCore.
If that target ever needs an import Linux cannot satisfy, the thing being added is on the wrong
side of a seam.
What is here so far
SourceSelection |
the selection vocabulary, identical to Android's and macOS's |
Chunking / ChunkBitmap |
how a file becomes parts, and which parts the far side confirmed |
Destination / ItemStream |
the route seam and the bytes seam |
TransferDriver |
the one uploader; both routes go through it |
PhotosConnector |
enumeration, albums and authorisation, behind the PhotoKit guard |
Building and installing on a device
An .ipa needs the iOS SDK, so this part happens on a Mac. Two steps, deliberately separate:
what gets built has nothing to do with whose certificate signs it.
Scripts/make-ipa.sh # -> build/GPBSync-unsigned.ipa
Scripts/sign-and-install.sh # sign with your certificate, install over USB
make-ipa.sh generates the Xcode project with XcodeGen, archives unsigned, and assembles
Payload/ by hand. Unsigned is the point: a sideloading service re-signs with whatever
certificate you actually have, and handing it something already signed by a different identity
means it has to strip that first — which several of them do badly. If you sideload through a
web service, this is the file you upload and you can stop here.
sign-and-install.sh does the same job locally, which turns a round trip into a few seconds
once you are changing the app more than once a day. It expects three files, once:
~/.ios-signing/cert.p12 certificate + private key
~/.ios-signing/cert.p12.password that export's password, one line, chmod 600
~/.ios-signing/profile.mobileprovision the profile that goes with it
Override the location with IOS_SIGNING_DIR. It lives outside the checkout on purpose: this
repository may sit in a synced folder, and a private key that can sign code in your name is the
last thing that should reach a cloud or a commit.
Write the password with printf rather than echo — a trailing newline becomes part of the
password, and security import reports only that the import failed.
Three things decide whether the phone accepts the result, and all three fail with messages that point somewhere else:
- Entitlements are the intersection of what the profile grants and what the app needs. Claiming one the profile does not grant is rejected at install with a message blaming the certificate — and so is claiming one it does grant for a capability the bundle was never provisioned for, which is what happens if you copy a resold profile's kitchen sink wholesale.
- The bundle id must be one the profile covers. A wildcard app id covers what is already
there; a specific one does not, so the script rewrites
CFBundleIdentifierto match. - Nested code is signed before the bundle containing it, or the outer signature seals an unsigned frameworks directory. There is none today — the package links statically.
The certificate is imported into a throwaway keychain that is deleted on every exit path, including failure. Importing into the login keychain leaves a signing key behind and pops an authorisation dialog on a machine nobody is sitting at.
What is not here yet
Pairing and the credential store, the /api/v5/sync control channel, the item journal, the two
concrete destinations (relay and LAN peer), the local-transfer identity, the probe runner, and the
interface. Each has a counterpart in the other two clients to port from.
Two things iOS does differently
An asset can be in several albums at once. On Android a photograph lives in exactly one
BUCKET_ID. So SourceSelection.includes(collections:) is asked of a set: under
onlySelected being in any chosen album is enough, and under exceptSelected being in any
excluded album is disqualifying.
.limited photo access is a real state, not a degraded .authorized. Under it the library
answers only for assets the owner picked, and every count reported is a count of those. Presenting
that as the whole library would have an owner believe their photographs are backed up when most
were never visible to the app.
The rules that are not negotiable
Taken from the standard, repeated because each one is silent when broken:
- A declare answering
NEEDS_TRANSFERis not permission to upload. An assigned task carrying ataskIdis. - There is one uploader. Both routes go through
TransferDriver. - The far side owns the chunk count. Disagree and refuse to send.
- An uncertain outcome is never answered by starting again — it is answered by asking what landed.
- Pin the receiver's certificate; do not name-check it. It is dialled at a bare IP.
- Nothing ever takes custody of a photograph.