No description
  • Swift 95.8%
  • Shell 4.2%
Find a file
dtecx a65b720138 fix(ui): stop claiming the phone owes bytes it has already sent
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>
2026-09-23 01:45:26 +02:00
App fix(ui): stop claiming the phone owes bytes it has already sent 2026-09-23 01:45:26 +02:00
Scripts refactor(ios): name the signing folder for what it is 2026-09-22 22:54:31 +02:00
Sources fix(sync): write the outcome before acknowledging it 2026-09-23 01:33:01 +02:00
Tests/GPBSyncCoreTests fix(sync): write the outcome before acknowledging it 2026-09-23 01:33:01 +02:00
.gitignore feat(ios): sign locally with your own certificate and install over USB 2026-09-22 22:45:20 +02:00
Package.swift fix(ios): stop the app target and the package sharing a name 2026-09-22 22:39:01 +02:00
project.yml feat(ios): a real engine, Liquid Glass, pause, and background work 2026-09-23 00:54:02 +02:00
README.md refactor(ios): name the signing folder for what it is 2026-09-22 22:54:31 +02:00

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 CFBundleIdentifier to 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_TRANSFER is not permission to upload. An assigned task carrying a taskId is.
  • 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.