Knotless for iOS
A native app for reading knotless: inbox, pulls and their diffs, repositories and profiles. SwiftUI, iOS 18 and up, no dependencies. It reads knotless.dev's XRPC queries and writes through its procedures.
The project is generated, so edit project.yml, not an .xcodeproj:
brew install xcodegen
ios/run.sh # builds and runs in the simulator
KNOTLESS_ACTOR=<handle> ios/run.sh inbox
Signing in
The app signs in through a browser, ending with the session a page's cookie
would hold, kept in the keychain and sent as that cookie
(docs/design/serve.md, Logging in).
A debug build can also ask as an account without signing in: with goat logged in
and KNOTLESS_ACTOR set, run.sh mints an hour's service token for each method
into build/.tokens.json and hands the app its path. That code isn't in a
release build.
TestFlight
Raise CURRENT_PROJECT_VERSION in project.yml for each upload, then:
cd ios && xcodegen generate
xcodebuild archive -project Knotless.xcodeproj -scheme Knotless -configuration Release \
-destination 'generic/platform=iOS' -archivePath build/Knotless.xcarchive -allowProvisioningUpdates
xcodebuild -exportArchive -archivePath build/Knotless.xcarchive -exportPath build/export \
-exportOptionsPlist ExportOptions.plist -allowProvisioningUpdates
build/export/Knotless.ipa goes up with Xcode's Organizer or Transporter. Signing
in needs webcredentials on Apple's CDN
(https://app-site-association.cdn-apple.com/a/v1/knotless.dev), as a release build
can't use developer mode: it can take a day or two after the file changes.
Making everyone update
An appview's dev.knotless.describeServer can name the oldest build it lets
run. A build older shows only a screen with a way to update, in place of the
whole app, with the reason if there is one:
# in .knotless/workflows/deploy.yml, landed as any pull
KNOTLESS_IOS_MINIMUM_BUILD: "5"
KNOTLESS_IOS_MINIMUM_REASON: "This version can lose what you write."
The number is CURRENT_PROJECT_VERSION. Raise it only once that build, or a
newer one, is live for the people it stops, on the App Store and on TestFlight:
the screen is all they can see, and there is nothing else to send them to.
Taking the setting out lets them back in, the next time the app comes to the front.
The app asks when it comes to the front and keeps the answer, so a build stopped stays stopped offline, and one that has never been told runs. A server that doesn't answer changes nothing. Its button opens TestFlight in a TestFlight build and the App Store in any other. Only builds from 5 up do this: older ones ignore the setting.
Sizes and colours
Every colour, space, corner radius, icon and avatar size is a token in
Knotless/Support/Theme.swift or Knotless/Support/Palette.swift: take one by
name, and add one there when none fits. The spaces follow the scale in
web/src/tokens.css. Never take an iOS colour, as Color(.systemBackground)
or Color.secondary, directly: the tokens stand for those where there's no
theme, and for a theme's colours where there is.
Themes
The app is drawn in the themes the pages are. Settings, Appearance chooses:
Same as knotless.dev (the default) follows the account's appearancePref,
day and night pair included; Automatic, Light and Dark are iOS's own colours;
then the themes the pages offer by name, and the account's own, those it
published and those it added. A palette leaves out what the pages fill in,
and Palette(record:) fills it in as tokens.css does.
A theme's colours come in as each is drawn: a token is a dynamic UIColor that
reads the theme in use (Themes), and a trait carried from the root
(ThemeTrait) draws them again when it changes, without rebuilding a screen.
As on the pages, only the main button takes a colour, the theme's primary
under its onPrimary; a state's pill is its colour under onSolid; and a
button beside the main one is quietButton, on the theme's surface.
Looking at a screen
just ios-look <screen> draws a screen in the simulator, light and dark, from
all the way down, as pictures in target/look, with no server and no account.
Look at what you changed before you push it:
just ios-look pull-open
just ios-look pull-open --set getMergeStatus.checks=failed --top --light
just ios-look pull-open --tap "Add to merge queue" --light
just ios-look pull-open --theme dracula --dark
just ios-look appearance --tap Ember
Its answers are what harness/shots/fixtures.json recorded for the web pages,
with what that lacks in look-answers.json. A write does nothing, so a --tap
that writes shows what's under way, not what comes after. ios/look.mjs lists
the screens and options. Each run starts with Appearance as a new install has
it, whatever the simulator was left in. It needs Xcode, so no check runs it.
Tests
KnotlessUITests taps through the app against live knotless.dev. It needs the
network and those tokens, and CI doesn't build iOS, so it is run by hand:
TEST_RUNNER_KNOTLESS_TOKENS=$PWD/ios/build/.tokens.json \
xcodebuild test -project ios/Knotless.xcodeproj -scheme Knotless \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-derivedDataPath ios/build -allowProvisioningUpdates
CI can't build the app, as Xcode needs macOS. On a pull, its ios check compiles
Knotless/API/Models.swift on Linux and reads the recorded XRPC answers with it:
ios/check-models.sh runs the same here.
Diagrams
A Mermaid block in Markdown is drawn by Pele, as the pages draw it
(Knotless/Support/Diagram.swift). Knotless/Vendor/pele.js is the version
web/package-lock.json pins: after raising it there, just ios-pele copies
it here, and the ios check fails while the two differ.
Icons
Assets.xcassets holds Octicons (MIT), the same set the web pages draw.