Phase 35 of docs/refactor/. The legacy KeyboardShortcuts_ key namespace and HotKeyBinding's hand-written Codable seam are gone: bindings persist under hotkey.<action> with the synthesised conformance, and HotKeyAction.defaultsKey stays the one place a key is computed. POLICY.md assumed there are no existing users. v0.7.5 is a shipped stable release, and both of the phase's deletions would have destroyed real user data on update: every hotkey silently unbound, and the entire clipboard history discarded, since v0.7.5 predates pinned_at and the store deletes a database it cannot open. Each cleanup therefore ships behind a one-time migration instead of a plain deletion. - LegacyHotKeyRecords adopts the shipped records once, reading both old shapes and never overwriting a key the user has already rebound. - ClipboardStore's two ALTER TABLE guards stay exactly as they are. Both are recorded in POLICY.md as scheduled deletions, to come out two stable releases after this one ships. Neither persists a flag, so each removal is a pure deletion. Net line count is positive as a result, which the phase document forbids; that is the deliberate cost of the migrations. AGENTS.md's refactor banner is removed and its hotkey clause amended. Raycast import and the snippet Markdown serializer are untouched: another application's format, not our legacy.
27 KiB
Project
Tinycast is a native macOS menu-bar launcher (a minimal Raycast): fuzzy app launcher, global +
per-app hotkeys, a text/image clipboard history, an inline calculator, and an emoji picker. SwiftUI +
AppKit, runs as an accessory (no Dock icon, LSUIElement). Targets macOS 26+ (Liquid Glass) and
builds with the Xcode 26 toolchain.
- Build: XcodeGen owns the project —
Tinycast.xcodeprojis committed but generated fromproject.yml. After editingproject.yml, runxcodegen generateand commit. There is noPackage.swift/ SwiftPM. Full build/test/sign/release steps:docs/development.md,docs/signing.md. - Channels: Debug builds are their own channel —
Tinycast Dev.app/com.tinycast.app.dev— so a local run never shares prefs, caches, TCC grants or login item with an installed stable/beta. Anything newly persisted must stay keyed byBundle.main.bundleIdentifier. - Tests: no XCTest target — standalone
swiftcharnesses inTools/(see Critical Invariants anddocs/development.md).
Project Philosophy
General engineering principles — simple over clever, declarative views, Swift 6 isolation, remove dead
code — come from the global CLAUDE.md and are not repeated here. Tinycast adds one rule of its own:
Comments — minimal code, not annotated prose
- One line. Never two consecutive comment lines. If it needs two, it needs a named function, a named constant, or a type.
- Hard cap: 100 characters, including indentation. Longer belongs in
docs/<subsystem>.md. - Comment the why, the gotcha, or the invariant. Never restate the code, never narrate a sequence, never argue a decision at length in-line.
///on a public type or method is exempt from rule 1, not from rule 2.- Prefer deleting a comment to updating it.
- Never add a comment explaining a change you just made. The diff is not the audience.
Rules 1 and 2 are checkable, and both must come back 0:
find Tinycast -name "*.swift" ! -name "*.generated.swift" -exec \
awk '/^[[:space:]]*\/\//{r++; if(r==2) b++; next} {r=0} END{print b+0}' {} \; | awk '{s+=$1} END {print s}'
grep -rhE '^\s*(//|///)' Tinycast --include="*.swift" | awk 'length>100' | wc -l
DesignSystem/Scrolling/EdgeDissolve.swift and ThinScrollbar.swift are the only exemptions, because
they are off-limits entirely.
Architecture
Full detail: docs/architecture.md.
- Single-owner core.
AppCore.shared(App/AppCore.swift) is a@MainActorsingleton owning every long-lived manager and the window controllers.AppDelegate.applicationDidFinishLaunchingcallsAppCore.shared.start()and nothing else — that is the one wiring point. Palette / paste / launch actions are methods onAppCorethat views call. - Mostly AppKit windows.
TinycastApp(@main) declares only aMenuBarExtrascene. The command palette is a borderless floatingNSPanelhosting SwiftUI; Settings/About are plainNSWindows viaAuxWindowController. SwiftUISettings/Windowscenes are deliberately avoided (unreliable for accessory apps). - Subsystems: palette · launcher & fuzzy match · calculator · clipboard · emoji · snippets · quicklinks · window management · hotkeys · uninstall · Raycast import · UI & design system.
Critical Invariants
Never break these without an explicit task to do so.
AppCoreis the sole owner. New long-lived state belongs onAppCore, wired instart(); don't create competing singletons or wire managers elsewhere.PaletteWindowControllersolely owns the palette frame. The hosting view setssizingOptions = []so SwiftUI never drives the window size — otherwise the top edge drifts on the compact↔expanded swap.- The app is locked to
.darkAquaglobally. The Liquid Glass material is tuned for a dark surface only; do not add light-mode styling. - The flat
selectionindex must match the visible row order exactly, including the inline calculator card at index 0 when present. Selection is the single source of truth for highlight / activation.Features/PaletteRowIndex.swiftis that mapping, and it stays Foundation-only and pure — no SwiftUI, no AppKit — soTools/palette-selection-test.swiftcompiles the shipped type rather than a copy. Section headers are not selectable and never consume an index. AppEntry.Kindis the only thing that says what an entry is. One case per launcher section, perVisibilityStorecategory and per Settings pane — never re-derive a category by sniffing an entry ID (that's whatisCustomCommandused to do). A new category means a new case, a slice inAppIndex.publishEntries(), and the matching filter inLauncherList.rows, in that same order.- While a footer menu is open the palette search field never resigns first responder — input is frozen instead (resigning shifts the text a point or two). See palette.md.
- Focus restoration is load-bearing. Paste targets the recorded
previousAppand requires the Accessibility permission (Permissions.ensureAccessibility()). See palette.md. Features/Calculator/Model/(incl.CalcDateTime) must stay Foundation-only and pure — no AppKit / SwiftUI imports, no clock or network reads.Tools/calc-test.swiftcompiles the real engine sources. Both externally-sourced inputs are injected: the clock vianow/calendar, the FX table viarates(CurrencyRateStoreowns the fetch). LikewiseFeatures/Emoji/Model/(EmojiCatalog,EmojiGridGeometry) stays AppKit/SwiftUI-free forTools/emoji-test.swift, andFeatures/Clipboard/Model/ClipboardStore.swiftmust keep to Foundation + SQLite3 with no other app source, soTools/clipboard-test.swiftcan compile it standalone.Launcher/Model/LauncherRankingStore.swiftis the same deal forTools/ranking-test.swift— Foundation only, with the clock injected vianowand the store path viafileURL, as isLauncher/Model/SearchScopes.swiftforTools/scopes-test.swift.CustomCommands/Model/CustomCommand.swiftandCustomCommands/Service/ShellCommandRunner.swiftmust stay free of AppKit / SwiftUI (Foundation plus Darwin formkstemp) soTools/custom-command-test.swiftcan compile them standalone — which is why the custom-command confirmation gate lives inAppCoreand not in the runner. All ofFeatures/Snippets/Model/andService/compiles intoTools/snippets-test.swift(it globs both), so the model, Markdown serializer, template engine, repository and keyword policies stay Foundation-only, and the AppKit files there keep their dependencies to what the harness can stub.SystemActions/Model/SystemAction.swiftis also Foundation-only forTools/system-action-test.swift; platform effects belong inSystemActionRunner, while confirmation and failure UI remain inAppCore.SystemActions/Model/VolumeLevel.swiftis the same split forTools/volume-test.swift— the 5% step grid and the percentage string are pure Foundation, CoreAudio lives inSystemActionRunnerand observation inVolumeState, so both the HUD and the Set Volume slider walk one tested grid.Features/WindowManagement/splits the same way forTools/window-command-test.swift:WindowCommand.swift,WindowLayout.swiftandWindowActionMemory.swiftstay Foundation + CoreGraphics and pure (no AX, noNSScreen, no clock —WindowActionMemorytakesnowas a parameter), while everyAXUIElementcall and the Cocoa↔AX coordinate flip live inWindowMover.swift.WindowLayoutworks exclusively in AX space — global coordinates, top-left origin, +Y down.WindowMover.AXGeometryis the only place that converts, and it anchors the flip on the primary display's height, never the window's own screen: doing otherwise shears every rect on a differently-sized display by the height difference, which is invisible on one monitor and wrong on every mixed-size setup. The visible consequence is that "Top Half" hasminY == visibleFrame.minY;Tools/window-command-test.swiftasserts it. Nothing in this feature ever touchesbackingScaleFactor— all three ofNSScreen.frame,visibleFrameand AX coordinates are in points, so mixed-DPI correctness is automatic. See window-management.md.- Uninstall moves to the Trash and never deletes.
FileManager.trashItemis the only removal call in the feature;removeItemmust never appear there. That is what makes display-name attribution tolerable — a false positive costs a drag back, not the user's data — so a "delete permanently" option would have to drop name matching in the same commit. The deciding half (UninstallTarget.swift,UninstallSearchRoot.swift,UninstallRules.swift,UninstallProtection.swift,UninstallPlan.swift) stays Foundation-only and pure forTools/uninstall-test.swift, with every environment fact injected: the scanner hands the rules directory names, never URLs, and hands the classifier aPathFacts. EveryFileManager,lstatand Full Disk Access read lives inUninstallScanner, which detects FDA (a silent, promptless probe) and never requests it — this feature asks for no permission and never escalates privilege.tccRelativePrefixesis measured, not assumed: probe a location by creating and trashing a throwaway directory there before adding it, because listing is not the test —~/Library/Containersenumerates fine and still refuses the move, while~/Library/Application Scriptsallows it. A locked candidate can never enter the checked set; that invariant lives inUninstallSelection's one intersection, not in the view. Tinycast also refuses to plan its own uninstall, compared against the running identity so the Dev channel refuses itself too. See uninstall.md. - Quicklinks are authored data, and their store never deletes.
Quicklinks/Model/splits likeUninstall/Model/:Quicklink.swift,QuicklinkDestination.swift,QuicklinkStore.swiftandQuicklinkArchive.swiftstay Foundation-only (plus SQLite3) and pure forTools/quicklink-test.swift— the home directory is injected, never read — whileQuicklinkLauncherowns everyNSWorkspacecall andQuicklinkArgumentSessionthe prompt state. The database lives in Application Support, not Caches, and a database that won't open is reported, never discarded:ClipboardStore's delete-and-recreate is only sound because history is regenerable, and a link library is not.Quicklink.precedesis the one display order, sorted through by both the store and theAppIndexslice. There is one template engine: quicklinks expand throughSnippetTemplateEnginerather than a second parser, which is what makes| rawmean something — it opts a value out of the automatic percent-encoding a URL destination asks for.{selectedText}is accepted as an alias for{selection}, but nothing ever writes it. See quicklinks.md. Launcher/Model/SearchRelevance.swiftis Foundation-only and pure, soTools/fuzz-test.swiftcompiles the shipped scorer rather than a copy of it. It owns bothFuzzyMatch(the tiered exact/prefix/word-start/substring/subsequence scorer) and the field bands. Searchable fields stay separate — display name, Spotlight alternate names, bundle id, executable name are never flattened into one string, because the field is what picks the band. Bands are onebandStrideapart, an order of magnitude aboveFuzzyMatch.maximumScoreand two aboveLauncherRankingStore's boost cap: that gap is what keeps a learned boost reordering within a tier and never across a tier or a field. A new searchable field means a newBandcase and aconsidercall, in priority order.EmojiData.generated.swiftis emitted bynode Tools/gen-emoji.jsandCurrencyData.generated.swiftbynode Tools/gen-currencies.js— never edit either by hand. Currency names, signs and uncontested nouns are generated (Frankfurter × CLDR); the only hand-maintained currency data isCalcCurrency.contested, the nouns several currencies share (dollars,pounds). Don't add slang or synonyms there — no source of truth, so they rot.- Every networked feature ships off and is consent-gated. Tinycast is offline by default; a
feature that reaches the network must be opt-in behind a Settings toggle whose dialog names the
provider, the cadence and what leaves the machine, and its owning store must re-check consent at
every entry point — including on both sides of the
awaitaround the request, since consent can be withdrawn mid-flight. Consent flags live on the owning store, never inAppSettings(SettingsBackupmirrors that type, and an import must not grant network access). Model the gate so the safe state is the default:CalcEngine.evaluate'scurrency:parameter defaults to.off, so forgetting to pass one disables the feature rather than enabling it. Fetch on a private cachelessURLSession(.ephemeral,urlCache = nil), neverURLSession.shared— a cacheable response would leave a second copy in the on-diskURLCachethat opting out doesn't delete.CurrencyRateStoreis the reference implementation — follow it rather than inventing a second shape. SettingsBackup's mirror is hand-written, and reflection is never the fix.AppSettingsKeyowns every keyAppSettingspersists;SettingsBackupCoveragesays which of themSettingsDatacarries and which are excluded — today onlysnippetsEnabled, whose omission is a security control, since it doubles as keystroke-listening consent. AMirror, macro or codegen scheme would auto-include the next consent flag and make it backup-restorable, soTools/settings-backup-test.swiftfails instead when a key is neither carried nor excluded with a reason. Adding a setting means editingSettingsBackupCoveragein the same commit.CurrencyRateStore's flag stays outside both by design.- Snippets are channel-isolated and path-identified. Persist them under
~/Library/Application Support/<bundle-id>/Snippets/;StoredSnippet.IDis the standardized source path, and external rename is delete + create. The feature ships off and its enable switch doubles as keyword-expansion consent:snippetsEnabledis excluded from settings backups, and Accessibility — the only permission, since the listen-only tap needs nothing more — may be requested only from that explicit Settings gesture, never from startup, callbacks, watchers or health checks. See snippets.md. - The two Raycast export formats share no mapper.
RaycastFormat.detectis the only branch between them;RaycastImportV1andRaycastImportV2own their own decrypt and their own field mapping, and neither is ever tried as a fallback for the other (that is what makes a wrong passphrase report a wrong passphrase instead of "not a Raycast export"). They meet only atRaycastImport.Result.RaycastFormat.swiftandRaycastV1Decoder.swiftstay Foundation + CommonCrypto + Carbon soTools/raycast-test.swiftcompiles them standalone, which is why the decoder returns Raycast's own values andRaycastImportV1— not the decoder — validates them againstPopToRootTimeout/EmojiSkinTone/HyperKeyPhysicalKey/KeyShortcut. Never commit a real.rayconfigas a fixture: the harness builds its own. See raycast-import.md. - Swift 6 language mode: data-race violations are hard errors. Almost everything is
@MainActor; cross-actor model types areSendable; heavy / IO work (app scan, image decode) is pushed off-main viaTask.detached/nonisolated. Keep that boundary. House idioms:NotificationToken(RAII) for block observers,isolated deinitforClipboardStore's SQLite teardown, decode raw Carbon / C pointers to plain values before crossing into actor code. - Clipboard writes stamp a private
internalTypemarker so the poller skips Tinycast's own writes. - Hotkeys persist as JSON strings under
hotkey.<action>UserDefaults keys, andHotKeyAction.defaultsKeyis the one place that computes a key — it is also theHotKeyCenterregistration id, so the two can never drift.HotKeyBindingis the one thing an action is bound to and it has two cases with two engines: a.combois a Carbon registration, a.doubleTapis recognized byDoubleTapMonitor(Carbon cannot see a lone modifier at all). ItsCodableis the synthesised one;KeyShortcut's hand-writteninit(from:)is not a format seam but a correctness one, routing every decode through the initializer that masks device modifier bits off.LegacyHotKeyRecordsadopts the shippedKeyboardShortcuts_records once and is scheduled for deletion — see POLICY.md; nothing new may depend on it.HotKeys/Model/DoubleTapModifier.swiftandDoubleTapDetector.swiftstay Foundation-only and pure with the clock injected as a parameter, forTools/hotkey-test.swift; everyCGEventcall lives inDoubleTapMonitor.swift, which is listen-only, installs only while something is bound to a double-tap, and never prompts for Accessibility. See hotkeys.md. - Tinycast presents its own dialogs, never
NSAlert/NSSlider/ system popovers. Every confirmation, failure report and value prompt goes throughDialogController(Windows/Dialog/, owned byAppCore; reachable elsewhere viaAppCore.showNotice/confirm). Presentation isasync, so there is no nested run loop, and the presenter refuses a second dialog while one is up — that, not a flag, is what stops a held hotkey stacking dialogs. ↵ runs the primary action, Escape cancels, and Cancel always renders leading (the left button), matching macOS convention. - A dialog has three independent axes; never let one infer another. The icon
(
DialogRequest.symbol, required) is always the subject's own glyph — the command being confirmed uses itsSystemAction.sfSymbol, so the Restart dialog shows the same icon as the Restart row. Tone never picks an icon. The tone (DialogTone:.neutral/.success/.danger) tints only that glyph. The button takes its color fromDialogAction.Role(.standardwhite /.destructivered /.cancelsecondary), so a red-glyph security warning can still carry a plain white button — as "Import executable commands?" does. Resolve every glyph throughSymbolImage, notImage(systemName:): some catalog symbols are bundled assets (toggleBluetooth). A button never prints its key cap; hovering it shows aTooltip(DesignSystem/Tooltip.swift) instead, styled like the palette's own keycap chips. - A transient readout is a HUD, not a dialog.
VolumeHUDController's box is volume/mute only, since that one needs an actual level and number; every other success/info confirmation (system commands, Custom Commands, Snippets) goes throughMessageHUDController's pill, whose trailing glyph is itsDialogTone— a pill has no subject to name, so the dialogs' icon rule doesn't apply, and the mapping stays file-scoped so nothing can reach for it when building aDialogRequest. Both are driven byHUDPresenter, which owns the one-at-a-time / auto-dismiss / fade policy; a new HUD means a new presenter, not a second shape bolted onto an existing controller. See ui.md. - Glass is for controls; content takes the panel recipe.
glassEffectneeds a backdrop to lens, so it only works inside a window that already has aVisualEffectView— the action capsule, the menu circle,PopoverMenu, a dialog's buttons. On a bare borderless panel it falls back to an opaque backing and shows as a dark edge. Both HUDs therefore useblack panelDimming→VisualEffectView()→clipShape, exactly like a dialog. - Read
docs/ui.mdbefore any restyle or new view.DesignSystem/Theme.swiftis the single design-token source. DesignSystem/Scrolling/EdgeDissolve.swiftandDesignSystem/Scrolling/ThinScrollbar.swiftare off-limits. Both are tuned by eye against the palette's floating bars, so any edit is a visual regression. Do not touch them to fix a scroll bug, and never as a side effect of a restyle or refactor — needing to is the signal that the real fix belongs elsewhere (a scroll target, an inset, an intent). Edit either one only under an explicit task to change that look.
Project Layout
Tinycast/DesignSystem/— the shared visual primitives:Theme.swiftis the design-token source, plusKeyCapChip,Tooltip,SymbolImage,VisualEffectView,PopoverMenu,SettingsComponents,Scrolling/andInteraction/.Tinycast/Platform/— the system shims:Permissions,LaunchAtLogin,CursorScreen,AppDisplayName,NotificationToken,AppPaths,SignpostsandImages/.Tinycast/Palette/— the palette shell:PalettePanel,PaletteWindowController,RootPaletteView, thePaletteScreenprotocol,PaletteCoordinatorand thePaletteState/PaletteMode/PasteTargetstate types.Tinycast/Windows/— the non-palette AppKit surfaces:AuxWindowController(Settings, About, Onboarding),Dialog/andHUD/, kept separate because a dialog asks and a HUD reports, plusAbout/.Tinycast/Features/<Name>/— one folder per feature, holding everything that feature owns:Launcher/,Clipboard/,Calculator/,Emoji/,Quicklinks/,Snippets/,Uninstall/,SystemActions/,CustomCommands/,HotKeys/,Backup/,WindowManagement/,Onboarding/. Larger features split intoModel/(pure — the standalone-harness inputs),Service/(effects: stores, monitors, runners, AppKit glue),UI/(screens, views and the feature's coordinator) andSettings/(its own panes). A file underModel/may not import AppKit or SwiftUI — that is the checkable form of the purity invariants above. Small features stay flat.Features/PaletteRowIndex.swiftsits at the top level because the palette, not one feature, owns it.Tinycast/Features/Settings/— the Settings shell only:SettingsRootView,SettingsTab,AppSettings, andPanes/for the two panes no feature owns (General, Permissions). Every other pane lives with its feature. EachSettingsTabmaps to one…SettingsViewbuilt on theSettingsPane/SettingsCardscaffold inDesignSystem/SettingsComponents.swift; the four launcher-category panes (Applications, System Settings, System Actions, Commands) are thin wrappers over the sharedLauncherItemsCard.Tinycast/App/—@mainapp, delegate andAppCore, the composition root.Tools/— standalone test harnesses and the emoji generator..github/workflows/release.yml— the entire release pipeline (seedocs/development.md).
Naming vocabulary
A type's suffix says what it is. Each row below is a suffix, what it means, and — where the set is small enough to enumerate — every type that currently carries it, so a reader can tell a closed set from an open one. The table governs top-level types; a private nested helper is free to be named for its job.
A new type takes an existing suffix, or this table gains a row in the same commit.
| Suffix | Means | Members |
|---|---|---|
Store |
Owns persisted state and publishes it | open (10) |
Coordinator |
A feature's action surface, called by AppCore and the palette |
open (11) |
Controller |
Owns one AppKit window or surface | open (5) |
Catalog |
Pure static namespace over a built-in list | CommandCatalog, EmojiCatalog, SystemActionCatalog, WindowCommandCatalog |
Index |
A searchable collection, rebuilt as its inputs change | AppIndex, EmojiIndex, PaletteRowIndex |
Runner |
Performs one effectful operation on request | ShellCommandRunner, SystemActionRunner, UninstallRunner |
Session |
Transient state for one in-progress interaction | QuicklinkArgumentSession, ShortcutCaptureSession, UninstallSession |
Policy |
A pure decision — no state, no effects | the three Snippet…Policy types |
Engine |
A pure evaluator: input → output | CalcEngine, SnippetTemplateEngine |
Monitor |
Watches an external stream and reports changes; owns no policy | DoubleTapMonitor, RunningAppsMonitor |
Scanner |
Reads the filesystem to produce candidates | SettingsPaneScanner, UninstallScanner |
State |
Shared observable state that persists nothing itself | OnboardingState, PaletteState, VolumeState |
Manager |
Closed set of two. Sole owner of a subsystem's lifecycle and its policy, started from AppCore.start(). Do not add a third — a new type wanting this suffix wants Store, Monitor or Coordinator instead. |
ClipboardManager, HotKeyManager |
Manager survives on two types because neither alternative is honest. ClipboardManager polls, but it
also owns the capture policy (sensitiveTypes, maxTextLength, internalType, the disabled-apps
filter) and the paste-side handshake, which the two real Monitors do not. HotKeyManager persists
bindings like a Store, but it also drives Carbon registration and double-tap dispatch.
Exceptions, each earning its own word:
Launcher(AppLauncher,QuicklinkLauncher) — reserved synonym for anNSWorkspace.openwrapper. Clearer thanRunnerfor opening things.Repository(SnippetRepository) — conflict-detecting, revision-checked file semantics thatStoredoes not imply.Center(HotKeyCenter) — the Carbon registration layer specifically.Presenter(HUDPresenter) — owns the one-at-a-time / auto-dismiss / fade policy for both HUDs.- Domain terms with no better alternative:
SnippetTextInjector,WindowMover,HyperKeyTap. RegistryandViewModelare retired: a static table is aCatalog, shared app state is aState.- SwiftUI-layer suffixes (
View,Screen,Card,Row,Sheet) are a separate vocabulary and are not governed by this table.