Co-locate each feature's Model (pure, harness-compiled), Service (effects), UI and Settings under one folder, for thirteen features. Settings/ is reduced to the shell — SettingsRootView, SettingsTab, AppSettings — plus Panes/ for the two panes no feature owns. Tinycast/Core/ is gone: all 88 files rehomed, Features/ 51 -> 139, Settings/ 25 -> 5. Contents unchanged. 133 of 134 moves are 100% similarity; two of the three permitted splits (LauncherView, SettingsRootView) are cut-and-paste, proven byte-identical against HEAD. A file under Model/ imports neither AppKit nor SwiftUI, which is the checkable form of the purity invariants. Split 2 (ClipboardView) is reverted: AsyncThumbnail is private with callers on both sides of the boundary, so the split would need to widen it. All 17 harness command lines updated in docs/development.md, AGENTS.md, checklists/testing.md, checklists/build.md and ci.yml, plus both Tools/gen-*.js output paths — those write the generated sources and would otherwise recreate Core/ on their next run.
22 KiB
⚠ An approved architecture refactor is in progress
Some structural rules in this file — file layout, type ownership, which type does what — are being changed by it. That is deliberate, and a phase contradicting one of them is not an error.
If you are executing a phase from
docs/refactor/, the phase document overrides the architectural guidance here. Readdocs/refactor/prompts/system-prompt.mdfor the full precedence ladder, anddocs/refactor/POLICY.mdfor the migration and compatibility rules.Behavioral invariants below always hold — UI, keyboard behaviour, accessibility, permission and consent flows, Swift 6 data-race safety, and the explicitly off-limits files. A phase that contradicts one of those is wrong: stop and say so.
If you are not working from a
docs/refactor/phase, ignore this box entirely and follow this file as written.
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 are single-line — no stacked / multi-line blocks. Only comment the non-obvious (a why, a gotcha, a load-bearing invariant); never restate the code.
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. - 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 under legacy
KeyboardShortcuts_<name>UserDefaults keys (from the removed KeyboardShortcuts package) so old bindings survive.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). ItsCodableconformance is the compatibility seam — a.combomust keep encoding as the bare{"carbonKeyCode":N,"carbonModifiers":N}record and decoding must keep trying that shape first, or every existing binding and backup breaks.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 thePaletteViewModel/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).