Skip to content

Latest commit

 

History

History
195 lines (148 loc) · 10.6 KB

File metadata and controls

195 lines (148 loc) · 10.6 KB

Development

The local loop: set up, build, run, regenerate. Shipping a build is release.md; verifying a change is testing.md.

Requirements

  • macOS 26 or later (Liquid Glass).
  • Xcode 26 — it provides the SwiftUI macro plugin and the SDK.
  • XcodeGen, and for linting: brew install swiftlint.
  • Node, for the generators and for the two stub servers run-tests.sh drives. It is the only scripting runtime here — building the app still needs none of it.

First-time setup

Create the Tinycast Self-Signed code-signing identity once — builds sign with it, which is what keeps macOS from forgetting the Accessibility grant on every rebuild. Follow signing.md §1, a few openssl/security commands.

That is the whole required setup. Editor configuration is personal and the repo does not prescribe it; the section below is a note for anyone who wants it, not a step.

Build & run

open Tinycast.xcodeproj    # then ⌘R

Or from the command line:

xcodebuild -project Tinycast.xcodeproj -scheme Tinycast -configuration Debug build

xcodebuild uses whatever xcode-select points at; if that's the Command Line Tools rather than Xcode, prefix with DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer (the SwiftUI @State/@FocusState macros need Xcode's macOS platform).

Tinycast.xcodeproj is committed and generated from project.yml via XcodeGen — after changing project settings in project.yml, run xcodegen generate and commit the result. There is no Package.swift, and Bundle.module must never be used.

The app target builds and embeds ClipboardTextHelper under Contents/Helpers, signing it on copy. Build the app scheme to include it; copying only the main executable omits OCR support. The helper's executable name stays fixed even when release builds override the app's product name for a channel.

The dev channel

Debug builds are a separate channel: Tinycast Dev.app, bundle id com.tinycast.app.dev. Every persisted thing is keyed by bundle id — ~/Library/Preferences/<id>.plist (settings and hotkey bindings), ~/Library/Application Support/<id>/ (the onboarding marker, Notes, snippets, quicklinks, clipboard history, calculator history, launch ranking and frequent emoji), ~/Library/Caches/<id>/ (exchange rates, the update check, staged downloads), the SMAppService login item, and the Accessibility / Input Monitoring (TCC) grants — so a local build can neither read nor clobber an installed app's state, and both run side by side.

What earns a place in Caches is refetchable, and nothing else. Anything the user would notice the loss of goes in Application Support: ~/Library/Caches is excluded from Time Machine and the system reclaims it under disk pressure without saying so.

Consequences worth knowing:

  • The dev build asks for Accessibility on its own the first time, and starts with no hotkeys bound and onboarding unseen. Grant and bind once; it persists across rebuilds, because the fixed build path and the Tinycast Self-Signed identity keep the TCC grant alive.
  • Don't bind the same global hotkey in both — whichever registered first wins.
  • The Hyper Key's Caps Lock remap is hidutil state, which is system-wide, not per-bundle: quitting one build clears the remap for the other, which then needs a rebind or a relaunch to restore it.

Editor

Xcode works out of the box and needs nothing here. Everything below is optional, and which editor you use is your business — the repo prescribes none of it.

VS Code gets code intelligence from SourceKit-LSP, which needs a buildServer.json because there is no Package.swift. Build once, then hand the log to the sync script — that writes both buildServer.json and the flag database:

brew install xcode-build-server
xcodebuild -project Tinycast.xcodeproj -scheme Tinycast -configuration Debug \
    -derivedDataPath build/DerivedData build 2>&1 | tee /tmp/tinycast-build.log
./Scripts/sync-lsp.sh /tmp/tinycast-build.log

Both files are git-ignored because they embed absolute paths, and sourcekit-lsp looks for buildServer.json at the workspace root by name, so it cannot live in a subfolder. After this the Build Tinycast.app (debug) task (⌘⇧B) and F5 re-run the script on every build, so new and renamed files keep resolving.

Do not run xcode-build-server config. It writes kind: xcode, and in that mode the server ignores .compile entirely — it serves flags from a cache it scrapes out of .xcactivitylog instead. That cache is only refreshed when LogStoreManifest.plist advances, and when the manifest stops updating (it does) the editor silently pins itself to the source list from some older build: every reference to a file added since reads cannot find type X in scope, in every file, until you restart the server. It also mixes Release entries in with Debug and lets them win. Scripts/sync-lsp.sh keeps the mode manual, where .compile is the single source of truth.

Symbols in Tests/

xcodebuild never compiles the harnesses — they are not in the Xcode project — so nothing emits a compile command for them, and without one an open harness reports every shipped type it uses as cannot find in scope. Measured on fuzz-test.swift: 60 errors with no entry, 0 with one.

./Scripts/run-tests.sh --index    # merge the harness compile commands into .compile

It reads the source lists from run-tests.sh itself, so they cannot drift from what the suite actually compiles. Scripts/sync-lsp.sh runs it too. Three things it has to get right, all of which fail silently otherwise: every path is absolute, because sourcekit-lsp resolves the command itself and does not apply directory to relative arguments; the command carries an explicit -sdk; and each entry claims only files under Tests/ — its harness plus any helper compiled beside it. The command still lists every shipped source it compiles, so symbols resolve inside the harness, but claiming a shipped source too would hand it this three-file command instead of the app's, and .compile is last-wins.

A benchmark that stays out of the suite still needs flags, so run-tests.sh registers it as run index <name> <source...>: --index emits its compile command and the runner never queues it.

Re-run it after adding a harness, then Swift: Restart LSP Server from the Command Palette — an already-running server does not re-read .compile.

Linting

./Scripts/lint.sh          # lint the whole project
./Scripts/lint.sh --fix    # auto-correct the mechanical subset first

SwiftLint is the only code-quality tool here. .swiftlint.yml at the repo root excludes the generated files and the two off-limits files in DesignSystem/Scrolling/. The comment policy in standards.md is deliberately not among its rules.

Formatting

./Scripts/format.sh            # format Tinycast/ and Tests/ in place
./Scripts/format.sh --check    # report what would change, write nothing (exit 1 if any)

swift-format from the Xcode toolchain — the same binary sourcekit-lsp formats with, so ⌘S in VS Code and this script cannot disagree. .swift-format at the repo root tunes it to this tree; without it the stock config defaults to 2-space indent and rewrites all 200 files.

Every *.generated.swift file is excluded: formatting one is hand-editing it, and the next node Scripts/gen-emoji.js would revert it. swift-format also refuses any file that does not parse, so a failure from either command is a syntax error rather than a tooling problem — and it is why ⌘S looks like it does nothing while a file is mid-edit with unbalanced braces.

Think twice before leaning on this. A formatter was rejected here on measured evidence, and that stands: running it over the tree touched 68 files, and 67 of those changed more than whitespace.

The config sticks to rules that catch defects and stays quiet about style, because there is no formatter, on measured evidence. Formatting is Xcode's re-indent (⌃I), as it always has been. Two consequences worth knowing:

  • empty_count is disabled, and isEmpty-style rewrites are unsafe here generally: LauncherRankingRecord and PaletteRowIndex have a count that is a hit count, not a collection count. A rule that rewrites count > 0 to !isEmpty on them does not compile.
  • force_try is an error; force_cast only warns, because the AX and AppKit bridges have four legitimate ones.

Errors block, warnings do not. CI runs this same script on every PR and annotates the diff with each violation — see release.md — so run it locally first rather than finding out from a review.

Generated data

Three Swift files are emitted by scripts and must never be hand-edited. Each downloads its source, so run them online, then commit the result:

node Scripts/gen-emoji.js            # -> Tinycast/Features/Emoji/Model/EmojiData.generated.swift
node Scripts/gen-currencies.js       # -> Tinycast/Features/Calculator/Model/CurrencyData.generated.swift
node Scripts/gen-countries.js        # -> Tinycast/Features/Calculator/Model/CountryZoneData.generated.swift

gen-countries.js joins IANA's zone.tab with CLDR's en territory names on the ISO 3166 code. Re-run it when IANA adds or moves a country's zone; see calculator.md.

gen-currencies.js joins three sources on the ISO code: the fiat rate feed's own quote list — the same feed CurrencyRateStore fetches rates from, so the table and the rate source cannot drift apart — Unicode CLDR's en currency data, which supplies display names, signs and the singular/plural noun, and CLDR's supplemental currency data, which says which codes are still spent anywhere. That last one is not optional: the feed carries no retirement metadata and quotes codes their countries abandoned years ago. Both CLDR files are read from the pinned cldr-json checkout rather than the host's Intl, whose output shifts with the local ICU version and would make the file unreproducible.

Only unambiguous data is emitted. Anything two currencies claim — dollars, pounds, krona — is left out and decided by hand in CalcCurrency.contested. The crypto tickers aren't generated at all: they have no external source of truth, so CalcCurrency.crypto is hand-written, and that same list is the set of symbols the fetch asks for. Re-run the script when a currency is added or retired; nothing breaks in the meantime, since an unquoted code just reports "no exchange rate".