Bookmarks tag index: SwiftData store, streaming sync + iPad chrome fix
Created by: jedmund
Summary
Lays the groundwork for filtering bookmarks by artwork tag. No tag-filter UI in this PR — just the durable index it's built on, plus the sync pipeline that keeps it current, plus a fix for the iPad Bookmarks chrome that broke after the UIKit tab-controller migration.
What's in it
Storage — SwiftData-backed bookmark index
-
IndexedBookmark/IndexedTag/BookmarkIndexMetadata@Modeltypes; composite"<illustID>-<restrictRaw>"identity lets the same illust live in both public and private buckets independently. -
BookmarkIndexSchemaV1+BookmarkIndexMigrationPlanso schema changes get versioned migrations instead of ad-hoc JSON mangling. Escape hatch:resetStore()if a migration ever fails — the server's always authoritative. -
BookmarksIndexStore— dedicated actor, not MainActor. Owns theModelContextexclusively; only Sendable value-typed snapshots (BookmarkedIllustSnapshot,TagRef) cross the actor boundary.
Sync — delta + full + in-app hooks
- Delta sync — walks page 1 (plus overflow if all IDs are new), stops at the first fully-known page. Average cost ≈ 1 API call. 60s cooldown.
- Full sync — walks everything, prunes rows older than the sync start (bookmarks removed outside the app). Auto-picked after 7 days stale.
-
Auto mode —
startAutoSync(restrict:)picks full if stale else delta. Called fromSignedInRootwhen the Bookmarks tab becomes active. - Cross-restrict queue — public and private syncs are serialized through a single pending queue so they never fire concurrent requests at Pixiv's private API.
-
In-app mutations —
recordBookmark/recordUnbookmarkwire intoIllustDetailViewController.commitBookmarkso every bookmark POST updates the index immediately, no waiting for the next sync.
Rate-limit discipline (to not get the account banned)
- Strictly serial pagination per restrict.
- Public and private never sync concurrently.
- 150 ms between requests.
- Exponential backoff on 429 / 5xx: 1→2→4→8→16s, cap 30s. Three consecutive failures → pause that restrict for one hour.
- Triggered only by user intent (landing on Bookmarks). Never pre-fetches.
Sync toast UI
Transient glass-capsule card, pinned bottom-leading via GeometryReader + safeAreaInsets so it clears the iPhone home indicator. Up to two rows (one per restrict): syncing shows a 44pt thumbnail of the most recently indexed illust + fake-denominator progress bar + N indexed · #illustID; queued shows a clock placeholder and "Waiting for the other list to finish". Auto-hides when both restricts return to idle.
iPadOS 26 tab-controller fixes
Two separate bugs fell out of the UIKit tab migration, both verified against Console logs:
-
tabBarController(_:didSelect:)delegate never fires on iPadOS 26 UITab-based selection.selectedIndexKVO goes silent after its initial firing. Fix: each per-tabPixillateNavigationControllertags itself with itsUITab.identifierand overridesviewWillAppearto report selection back — the one lifecycle event UIKit is forced to call. Drives both the sync-on-tab-appear trigger and the accessory refresh. -
navigationItem.trailingItemGroupsdoesn't render on the floating pill tab bar (that API is nav-controller-hosted, not tab-controller). Fix: reparent theBookmarksTabBarRowChromehosted view directly ontoUIWindow, anchored tosafeAreaLayoutGuidetop + trailing. Window subviews composite aboverootViewController, which is the only layer that sits on top of UIKit's private tab strip.
Test plan
-
Sign in → open Bookmarks → sync toast appears, thumbnail updates per page, disappears after both public + private finish -
Switch to Home mid-sync → toast follows (root-level overlay, not tab-local) -
Open Bookmarks again within 60s → delta sync cooldown blocks re-run (no spam) -
Leave app for ≥ 7 days, reopen Bookmarks → full sync auto-fires (catches bookmarks removed on web) -
Bookmark an illust in the app → inspect SwiftData store, row exists with correct tags + restrict -
Unbookmark an illust → row is removed; next delta sync doesn't re-add it -
Switch restrict (public ↔ private) mid-sync → active row keeps going, requested other side shows "Queued" -
Airplane mode mid-sync → backoff + eventual pause; toast surfaces the paused state (UI stub for now) -
iPad: visibility menu + settings gear show in the top tab-bar row when on Bookmarks, disappear on other tabs and during detail pushes -
iPhone compact: inline chrome at top of Bookmarks content (unchanged behavior)
Follow-ups (not in this PR)
- Tag filter sheet that consumes
publicTagHistogram/privateTagHistogram - Feed rendering from the in-memory index when a tag filter is active (so filtered feeds aren't sparse)
- Manual "Refresh tag index" button in settings to force a full sync on demand
- Settings toggle to disable indexing entirely (the kill switch the rate-limit doc references)