Building requires Xcode and xcodegen. The app
target is generated from project.yml, so edit that rather than the project
file.
xcodegen generate
./scripts/sim.sh run # build and run in the simulator
./scripts/iPad.sh # build, install and launch on the iPad
./scripts/iPhone.sh # the same, on the iPhone
swift test # SZKit's tests./scripts/sim.sh also has seed (launch with the saved topic pages imported) and
paste (copy the Mac clipboard into the simulator). ./scripts/iPad.sh setup records the
connected iPad in .device and ./scripts/iPhone.sh setup records the iPhone in
.device-iphone; no-launch installs without bringing the app to the front. Both are
wrappers naming a device — the build, install and launch live once in scripts/deploy.sh.
The app's version lives in project.yml and nowhere else — Info.plist refers to
$(MARKETING_VERSION) and $(CURRENT_PROJECT_VERSION) rather than holding literals.
The build number goes up for every App Store upload, including a re-upload of an
identical build.
SZKit holds everything that is not UI — parsing, the SQLite library, archive
and PDF decoding — and builds on macOS, which is why the tests run without a
simulator. unrar and the LZMA SDK are vendored under Sources/CUnrar and
Sources/C7z. BuildSupport holds the caching fetcher the archive build tools share.
Saved pages under Tests/Fixtures/, one directory per source: retrospec, bombjack,
batcave, comicbookplus, stripovi, atarimania, vintageapple, popboks. They are public,
static pages carrying no private links, so those tests run on a fresh clone with no
network. Archive.org's are inline in the tests instead — trimmed copies of the real
metadata responses for the items the import was built against.
StripZona is the exception. Its fixtures are saved forum pages in spike/pages/, which is
gitignored to protect the mirror links. Uploaders there label issues in many ways; the parser
learns offline from that corpus and tries to handle each. If a topic imports badly, saving its
page into spike/pages/ and running the tests is the whole diagnosis: the counts say how many
of its links were understood.
Eighteen JSON files under Sources/SZKit/Resources/, each seeded by a switch in Settings.
They are rebuilt by hand, and every tool refuses to write a catalogue that fails its own checks.
swift run retrospec-build # retrospec-catalog.json
swift run archive-build # archive-catalog.json
swift run bombjack-build # bombjack-*.json (seven)
swift run atarimania-build # atarimania-catalog.json
swift run vintageapple-build # vintageapple-magazines.json, vintageapple-books.json
swift run popboks-build # popboks-dzuboks.json, popboks-ritam.jsonThey are Swift tools rather than scripts so they use the same code the app runs and the tests
cover. All but archive-build cache what they fetch — .retrospec-cache/, .bombjack-cache/,
.atarimania-cache/, .vintageapple-cache/, .popboks-cache/ — so a second run costs nothing and --no-network
rebuilds from the cache alone. Deleting a cache directory is how you ask for fresh copies.
archive-build makes a dozen requests to archive.org's metadata API and caches nothing.
vintageapple-build takes --group magazines|books to write one shelf rather than both.
popboks-build takes --magazine dzuboks|ritam to write one of the two rather than both.
The odd one in a different way from spectrum-build: almost all of its work is measuring.
Both magazines publish their entire index as literal arrays inside the script that draws their
front page, so there is nothing to crawl — two files and the index is read. What the scripts do
not record is the tile grid each page is cut into, and that archive stores no page images at
all: a page is a 5x7 grid of 256-pixel tiles which the app fetches and stitches back together.
So the tool probes every issue's first, middle and last page, walking outwards along two rows and two columns and keeping the largest answer. Two of each because the archive has holes in the measuring lines — one missing tile made Džuboks 88 measure four columns wide, and another made Džuboks 34 measure three rows tall, when both are ordinary seven-row pages. Either measurement would have shipped a catalogue that silently cropped real pages.
That is ~25,000 HEAD requests, cached, so only the first run costs anything.
spectrum-build is the odd one. ZXDB is an index of where scans live, not a store of them:
it records a URL template per magazine which, expanded against an issue's own numbers, names
that issue's scan on archive.org. So the tool has modes:
swift run spectrum-build --probe # what ZXDB holds; writes nothing
swift run spectrum-build --validate # do the expanded masks resolve?
swift run spectrum-build --plan # what a full build would ask for
swift run spectrum-build --build magazines|fanzines|booksIt works from a 27 MB dump cached under .zxdb-cache/, and expands masks with ZXDBMask.
Only the identifier half of an expanded mask is trusted: the build asks each archive.org item
what it actually holds, which is where the real filename and the exact byte count come from.
A line in ArchiveOrgLibrary.series — its identifier, in the run it belongs to — and another
swift run archive-build. Everything else (the scan's name and size, the page count, the cover,
the title's month and year) comes from the item's own metadata. That list stays hand-picked and
small; anything else worth reading is imported from the browser at runtime instead, which needs
no rebuild and ships nothing.
Local Files is a source in every way the shelf cares about — a row, a cover, a place in
the filters — and unlike any other in where the file comes from. There is no catalogue, no
mirror and nothing to download: the app's Documents directory is the source, and the
library is a view of it.
UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace in project.yml are what put
that folder in front of the reader — the Finder's Files list over a cable, "On My iPad" in
the Files app — and CFBundleDocumentTypes is what lets AirDrop, the share sheet and Open
With hand a file over.
Four of the seven formats have no type of their own on iOS. That is checked rather than
assumed: MobileCoreTypes.bundle inside the iOS runtime declares pdf, zip and 7z, and
neither rar nor any of the comic-book spellings. An extension nothing declares gets a
throwaway dyn.… identifier that no app can be matched against, so the missing four are
declared here or they cannot be opened at all.
The two declaration keys are not interchangeable. cbz/cbr/cb7 are exported — nobody
owns them the way a vendor owns its own format, and these are this app's spellings of them,
named after the bundle id because that is the one prefix another app cannot collide with.
They conform to public.data, not public.archive: a .cbr is as often a zip as a rar, and
the sniff happens when the file is opened. rar is imported, because RARLAB owns
com.rarlab.rar-archive and this is a declaration of somebody else's type, written so the app
defers to a real archiver's if one is ever installed beside it. It was referenced by the
document types before it was declared anywhere, which bound nothing — a plain .rar did not
open, and nothing said so. Both document-type entries also carry CFBundleTypeRole: Viewer;
an entry without a role is one Launch Services may skip, and the app views issues rather than
editing them.
Each comic-book format gets a document-type entry of its own, and the icons are the only
reason for the split: CFBundleTypeIconFiles belongs to the entry, so three formats sharing
one entry can only share one icon. scripts/document-icons.py draws them from the app icon's
own sampled colours — the starburst on blue, with the format where the "SZ" is, and the open
book dropped because it is the first thing to turn to mush at 64 points. The PNGs are
committed because the build needs them; the script is there so they can be redrawn rather
than edited by hand. They are named twice, as CFBundleTypeIconFiles here and
UTTypeIconFiles on the declarations, because which key iOS actually reads is undocumented
and the forum thread asking ends without an answer. The Alternate entry deliberately has no
icon: putting StreamZine's artwork on every zip on the device would be claiming in pictures
what the rank gives up in words.
iOS has no thumbnailer for comic archives, so a .cbz in Files gets a flat document icon —
unless some installed app supplies one, which any app may. That is worth understanding before
changing any of this: the picture a reader sees of their own comics otherwise belongs to
whichever other reader they happen to have installed, and disappears when they delete it.
Measured, not assumed: a freshly made .zip of images draws the generic archive icon while
the same file named .cbz drew a page, which is what proves the preview is type-driven and
comes from an app.
SZReaderThumbnail is our own, a QLThumbnailProvider over the three types the app owns.
Not offered for pdf, zip, rar or 7z — iOS already draws PDFs, and thumbnailing every
zip on the device is the overreach Alternate gives up.
The thing to understand before touching it is why it does not use ComicDocument.
ComicDocument is built so that page two is cheap: RAR and 7z unpack themselves whole on
open, because a reader is about to ask for the rest. A thumbnail wants one page, once, from a
file it will never open again — and it runs in an extension with a fraction of an app's
memory and a few seconds to live, asked afresh for every file scrolled past. Unpacking a
300 MB .cbr there is not a slow right answer; it is the wrong one.
So FirstPage is the other path, and the solid-archive argument that shapes ComicDocument
is what makes it possible rather than what blocks it: entry N costs everything ahead of it,
which is exactly why entry zero is the one entry a solid archive gives up cheaply. Zip
needed nothing — ZipReader already mmaps and inflates one entry. RAR and 7z each needed one
new function in our own C shim (szunrar_extract_first_image, sz7z_extract_first_image),
not a patch to vendored upstream. Both skip non-image entries by extension, because a .nfo
or a ComicInfo.xml ahead of the scans is common and deciding by content would mean
extracting the .nfo to discover it is one. FirstPageTests covers all three containers with
a solid RAR among them, and goes red in two places if that extension filter is removed.
Two things not to break: the scratch directory is deleted on every exit, or a thumbnail cache of full-size scans grows where nobody looks; and each request gets its own scratch, because a folder of comics means many of these at once and a shared one would have them deleting each other's page mid-decode.
Two traps this cost real time to find, both of which look identical from the outside — a blank tile of the right shape, nothing in the log, no crash:
QLThumbnailReplyhas two initialisers that differ only in the arity of their block.drawing:hands over a Core Graphics context;currentContextDrawing:sets up a UIKit one and passes nothing. A trailing closure taking one argument silently selects the first, soUIImage.draw(in:)inside it paints into no current context at all — successfully, and on to a blank bitmap. The fix is to draw through the context directly, which also disposes of the flip question: the block is Core Graphics' own coordinate system, so a CGImage drawn into it comes out upright. The label is written out at the call site for that reason.- Quick Look caches per item, and a failure caches too. Re-testing the same filename after a fix keeps showing the old blank result through rebuilds and reboots, which reads exactly like the fix not working. Copy to a new name to force a real request. That is what finally separated "the extension is broken" from "the extension was broken an hour ago".
The extension logs to subsystem com.mihailod.szreader.thumbnail, and that stays. A process
launched on demand and killed straight after cannot be attached to in any convenient way, and
both traps above were invisible until it could say what it was doing:
xcrun simctl spawn booted log show --last 5m --info \
--predicate 'subsystem == "com.mihailod.szreader.thumbnail"'
Verified on the simulator, which is the better oracle here precisely because it has no other
comic reader installed — any preview it draws is unambiguously ours. All three containers,
.cb7 included: asked … max=84x84 scale=2.0 then drew … page=112x168 reply=56x84, which
is the page's own aspect filling the requested box. The inset look in the Files grid is that
app's document-tile framing, not the reply under-filling.
One project-level consequence. The version moved from the app target up to project settings
once the extension existed: iOS refuses to install a bundle whose extension disagrees with it
about CFBundleVersion, and the App Store rejects the upload. Two targets reading one number
is the only arrangement that ships.
LSHandlerRank is split across two entries, and the split is the whole of what decides
whether an AirDropped issue opens here or is parked in Files for the reader to hand over
themselves. Owner for the three comic-book extensions — nobody owns those the way a vendor
owns its own format, and on a device with this app installed they are this app's. Alternate
for pdf, zip, rar and 7z, which belong to Books, to Files and to whatever archiver is
installed; becoming the device's default handler for every zip is not a comics reader's
business. Two things here not to go looking for a fix to: macOS AirDrop sends to a device
and has no way to name an app on the other end, so which app it lands in is the receiving
iPad's decision alone; and LaunchServices caches these registrations, so a rank change can
take a reboot to show up.
A file reaches the folder over a cable, from AirDrop or the share sheet, from Open With in
Files, or dragged onto the shelf from Files in the next pane over. Only the first needs no
code: the rest arrive at AppModel.adoptLocalFile (via onOpenURL) or adoptDropped, and
both funnel into take, which copies the file in and lets the ordinary scan put it on the
shelf. Copied rather than read where it lies — a file opened in place belongs to whoever
handed it over, and iCloud can evict it or the other app delete it out from under a row that
has no way to notice.
Two traps in that path, both of which look like nothing is wrong:
Documents/Inboxis notDocuments. iOS drops a hand-over into an Inbox folder that nothing ever empties, so a file taken in from there has to be moved, not copied. Deciding which isLocalFiles.isInInbox, and it resolves symlinks on both sides before comparing:FileManagernames the container/var/mobile/…while the URL handed over names the same file/private/var/mobile/…,/varbeing a symlink to/private/var. ThehasPrefixthis started as was false for every file it was asked about — everything appeared to work, and a second copy of every AirDropped issue accumulated out of sight.testInboxIsRecognisedThroughASymlinkis the guard, and it goes red without the fix.- A dropped file is a promise, not a file.
loadInPlaceFileRepresentationhands over a URL that stops resolving the moment its completion handler returns, sotakeisnonisolatedand synchronous and runs there. Hopping to the main actor first and copying afterwards copies nothing.
The fourth way in, and the one that exists because of a limitation nothing in this app can
declare away: iOS routes an AirDropped document to the Files app's Downloads folder, not to
whichever app claims the type. Owner rank does not change it and no Info.plist key asks for
it — the receiving device decides. Which left a reader holding a comic on their own iPad with
no route to the shelf except the share sheet, if they thought to look there. From Device is
that route: a fileImporter onto anywhere in Files, Downloads included.
It copies and never moves, which is the whole difference from the Inbox case in take — those
files are the reader's, sitting where they put them, and Downloads is somewhere they may go
back to. One scan for the batch, because picking eleven issues is one thing happening.
Two consequences worth knowing:
- The Import button is always a menu now. It used to have three shapes — a plain button for one source, another for none, a menu otherwise — on the reasoning that one source is not a choice. From Device never depends on a source being switched on, so there are always at least two ways in and the special cases went away. The "no importable sources" explanation moved into the menu as its own entry rather than being what the button did.
fileImporteris a presentation modifier. It sits on its ownColor.clearbackground like the alerts do, for the reason this file has had to learn more than once: SwiftUI honours one presentation per view, and put beside the settings sheet it is not a bug you see — it is a menu item that silently does nothing.
iCloud Drive shows everything the account holds, downloaded or not, and a file that is not downloaded is a placeholder — real name, real size, no bytes. Two things follow, and the feature is half-useless without both.
- The picker allows any file (
[.data]), where it used to name the seven readable types. Type filtering greyed out exactly the items this feature exists for: a placeholder's type does not reliably resolve to the one its extension implies, so filtered by type it cannot be tapped at all — and a file already downloaded is the easy case. The filtering moved to the way in, whereLocalFiles.isReadablewas already checked, and a refusal is now named in the status line rather than being an item that ignores taps for invisible reasons. - The bytes are fetched before the copy.
startDownloadingUbiquitousItemand then a poll onubiquitousItemDownloadingStatusuntil it reads.current, which is whytakewas split into itself andcopyIn: the security scope has to be held across both the fetch and the copy, and there is now something in between. Polled rather than observed — the proper instrument is anNSMetadataQuery, a live index over the whole ubiquity container, against one local metadata read twice a second here. Copying a placeholder without this gives an empty file and no error at all.
The copy itself moved off the main actor at the same time. These are hundreds of megabytes and the shelf should not stop scrolling for the length of one.
Not reproducible on the simulator, which has no iCloud account — this was found on the device and has to be confirmed there.
LocalFiles.scan lists the folder, flat, and Store.reconcileLocalFiles brings the rows into
line with it. Three things about that are worth knowing before changing any of it:
-
The filename is the identity — it is the row's
code. Not the path: the container's absolute path changes on reinstall, and every row would be a stranger after one. The path is rewritten from every scan, which is what heals it. -
A file still being copied is not a row yet. The folder is watched (
LocalFilesWatcher, aDispatchSourceon the directory), and a large issue's directory entry appears at the start of its copy — so the first thing a scan sees of a 300 MB file is a few megabytes.LocalFiles.settledholds one back until its mtime has aged or its size stops moving, andreconcileLocalFiles(_:present:)takes the names on disk separately from the files being written, so a mid-copy replacement does not delete the row it is replacing. -
Removals are as real as arrivals. A file dragged to the Trash takes its row, its unpacked pages and its captured cover with it. The cover matters more than its few kilobytes: SQLite hands the next row the id of a deleted one, so a stale
covers/<id>.jpgbecomes the artwork of an issue that no longer exists, sitting on top of one that does.That reasoning was right and the fix was half of one. Deleting
covers/<id>.jpgleavesCoverStore— which nothing ever evicted — still holding the picture under the same key, because a local cover is referenced asszpage:<id>and the id is precisely what gets reused. So the shelf went on drawing a deleted comic's artwork on top of a new one: import two issues after churning a few, and the second wears the first's cover.CoverStore.forgetclears all four stores (both colour variants, the monochrome verdict, the disk file), oneAppModel.discardCovercalls it beside the file delete so the five call sites cannot drift, andcaptureMissingCoverscalls it before recording a fresh capture — which is also what repairs a device that already went wrong, rather than needing its cache cleared by hand.Not unit-testable:
CoverStorelives in the app target, which has no test target. Verified by red/green on the simulator instead — import A, delete it, import a different B, and watch B wear A's cover withforgetstubbed out and its own cover with it restored.
LibraryPaths.owns is the rule that keeps the app off the reader's own files, and it exists
because three pieces of code that had always been right became dangerous the moment a recorded
path could point outside the comics root:
discardArchivesdeletes an archive once its pages are out — against a local file that is the reader's file, deleted out of the Finder, along with any sibling sharing a stem.AppModel.removeFromDiskdeleted a file and the folder it sits in. For a download that folder iscomics/<id>; for a local file it isDocuments.- Every bulk delete reaches files through the
downloadtable. Remove All, Remove Visible, Delete Visible and Delete Library now all pass oversite = 'local'(Store.notLocal), and the counts in their warnings were corrected to match.deleteLocalIssuesis the one path that removes them, and the app asks about it by name and by size after Delete Library.
Anything new that deletes, moves or unpacks a recorded path has to ask owns first.
LocalFilesTests covers the reconciler and the settle rule, and testOpeningALocalFileNeverDeletesIt
is the guard on the first of those three — it goes red without it.
The pattern, in order:
- Parser in
SZKit, not beside the build tool — the shipped catalogue has to be produced by code the tests cover. Add fixtures underTests/Fixtures/<source>/. - Build tool under
Sources/<Source>Build/, usingBuildSupport.Fetcher, writing aShippedCatalogand validating it before writing. IssueSitecase withdisplayandcatalogueResource. A source split into several switches gets aGroupenum in SZKit beside its parser, so a name, a resource filename and a switch label cannot drift apart — seeSpectrum.GroupandVintageApple.Group.SourceCopyentry for the switch label, the one-line description and the credit.SourceListinSourceToggle.swiftgroups multi-switch sources under one heading.- Add the download host to
DirectHost. This is the step that has been forgotten twice.DirectHostis scoped to a named allowlist on purpose, so an unrecognised forum link fails loudly rather than downloading an error page — and the consequence is that a catalogue whose host is missing producesnoHostForon every issue in it. Nothing else notices: the catalogue decodes, seeds and fills the shelf with rows that cannot be downloaded.ShippedCatalogHostTestsnow catches this, which is the only reason it is no longer a question of remembering.
Two things worth knowing before choosing a source. Seeding is fast — around 9,000 rows a second, measured — so a catalogue of a few thousand does not need splitting for speed; BombJack's seven exist because 18,219 rows took fifteen seconds, which iOS kills an app for during launch. And split a source by what the material is or what it is worth, not by size: the Sinclair shelves separate magazines from fanzines from books, and Vintage Apple separates magazines that archive.org already holds from books that it does not.
Four things sync, and each one merges by a different rule. Getting a rule wrong does not break anything loudly — it produces a library that quietly disagrees with itself — so the rules are worth knowing before touching any of it.
Settings ride NSUbiquitousKeyValueStore: ~33 flat keys, no schema, no container. Which keys
travel is a decision, not a default — syncedDefaultsKeys and deviceOnlyDefaultsKeys in
AppModel, and PreferenceKeyCoverageTests fails on any @AppStorage key in neither list. Three
keys are deliberately local because they describe this device rather than what the reader wants:
pageRenderStamp (how the thumbnails on this disk were drawn), retroSpecDefaultResolved, and
cloudLinksExplained (a sentence this device has shown to whoever is holding it).
Everything else rides CloudKit's private database, in a custom zone — only a custom zone answers "what changed since this token", and that question is the whole design. It makes the check on every foreground one cheap round trip that usually answers nothing, and it is the only way a deletion is distinguishable from an absence.
Only issues no catalogue can rebuild: 2,854 of 29,749 on a real library. The other nine rows in ten come from an index inside the app and are recreated by throwing a source switch — which already syncs. Sending them would be tens of thousands of records to reconstruct what a boolean reconstructs, and would race the seeder writing the same rows.
Local Files never travel, and not only because the files are absent: reconcileLocalFiles deletes
any site = local row whose file is not in the folder, so a synced local row would be created and
destroyed on the next scan.
Downloads never travel. They are third-party files, they are isExcludedFromBackup on purpose, and
a library of them is measured in gigabytes.
issue.id is a rowid and means nothing on another device. IssueIdentity is issue_identity_v3's
five terms — site, code, number, title_folded, series — SHA-256'd into a record name every device
computes identically, so two devices that imported the same page independently produce the same
record and the merge deduplicates itself. Zero collisions across 2,854 real issues.
Two details that look like fussiness and are not. title_folded is carried verbatim rather than
recomputed, because the import folds title ?? code ?? "" — recomputing from title alone invents
a second identity for every issue that has no title. And the terms are joined by a C0 control
character: a printable separator occurs in real titles, and ("a", "b|c") and ("a|b", "c") are
then the same issue.
| What | Rule |
|---|---|
| Pointers | Union, COALESCE on every field. A device that knows less cannot blank what this one knows. |
| Reading state | Newest wins, whole. |
| Deletions | A fact of their own, in deleted_record. |
Reading state is the one worth arguing about. Three of its four values are monotonic, so per-field
maxima look safe — and are not. Unmarking an issue clears read_at and started_at together, and a
per-field merge restores both from the other device's older copy, leaving an issue that can never be
unmarked while a second device exists. Hence read_state_at, which is not derivable from the four
columns beside it.
Union is right for the first merge and wrong as a standing rule: to a merge that only ever adds, an
issue deleted here is an issue the other device has and this one lacks, so it comes back. A deletion
is therefore recorded before the row goes — afterwards there is nothing left to derive its record
name from — and applied through deleteWithoutRecording, or two devices tell each other about the
same removal for ever.
modifyRecords(atomically: false) throws only on total failure. Per-record rejections sit in
saveResults, and discarding that value — the obvious thing to write — makes every rejection
silent while reporting success. What it was rejecting: a freshly built CKRecord carries no change
tag, and CloudKit will not let a tagless record overwrite one that exists. So creates landed and
updates did not, which is why pointers synced and reading state did not. CloudKitLibrary.write
now merges onto the server's copy and retries.
Nothing syncs unless something calls it. Three separate bugs, all the same shape: reading state
did not sync, then deletions did not, then a cold launch showed an empty shelf — each time the
engine was correct and nothing invoked it. scheduleSync() now lives in refresh(note:), the
chokepoint every shelf-changing mutation already goes through, with the reader's page turns asking
for themselves. The whole suite covers what a sync does and none of it covers when the app decides
to run one, which is where all three bugs were.
A column added for syncing is NULL on every library that already exists. read_state_at
needed a backfill or no pre-existing reading state would ever have left the device. Stamped old,
so any genuine change afterwards wins.
Deploy the CloudKit schema to Production — record types Issue and Reading, in the CloudKit
Console. It auto-creates in Development only, so a dev build works perfectly while the App Store
build talks to an empty database.
Development and Production are separate databases and the sync marks know nothing about the
difference, which would have left the first App Store build believing it had already sent
everything. LibrarySync now treats a full read that returns nothing, to a device whose marks
claim thousands sent, as a fresh account and re-uploads. That also covers clearing iCloud data by
hand.
iCloud.com.mihailod.szreader and the kvstore identifier are both one-way doors once shipped:
change either and every reader's data is orphaned.
A "restore my downloads" button. The mirrors rot — markMirrorDead and the "drugi sken" alternates
exist because of it — the file hosts rate-limit, which is what HostCooldown is for, and the
page-image sources are roughly a thousand requests an issue. A button that half-works and gets the
reader IP-blocked is worse than none. Instead a device with a full shelf and no files says so once
(SyncCopy.restoredLibraryMessage). If it is ever wanted, the next step is one synced boolean and a
"Downloaded elsewhere" filter — not a queue.