mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
* Bind Return to a modal's primary action and Escape to Cancel * Render Cancel on the leading edge of modal buttons * Split ModalKind into warning and error, and let custom carry its own color * Replace pill icons with a colored status dot * feat(modals): Highlight severe confirmations in red * feat(ui): Add modal tooltips and refine tones Use hover labels for dialog shortcuts, align warning and error styling, and default report dialogs to their settings action when available.
14 KiB
14 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
- Production-quality, as if written by a senior macOS engineer.
- Prefer simple, maintainable solutions over clever ones; preserve existing behavior unless the task changes it.
- Keep SwiftUI views declarative and lightweight; business logic lives in models / managers.
- Respect Swift 6 actor isolation; keep expensive work off the main actor.
- Remove dead code rather than adding compatibility layers. Leave the codebase cleaner than you found it.
- 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(Core/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 · window management · hotkeys · 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. - 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. Core/Calculator/(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). LikewiseCore/Emoji/(EmojiCatalog,EmojiGridGeometry) stays AppKit/SwiftUI-free forTools/emoji-test.swift, andCore/ClipboardStore.swiftmust keep to Foundation + SQLite3 with no other app source, soTools/clipboard-test.swiftcan compile it standalone.Core/LauncherRankingStore.swiftis the same deal forTools/ranking-test.swift— Foundation only, with the clock injected vianowand the store path viafileURL, as isCore/SearchScopes.swiftforTools/scopes-test.swift.Core/CustomCommand.swiftandCore/ShellCommandRunner.swiftmust likewise stay free of AppKit / SwiftUI (Foundation plus Combine forObservableObjectand 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 ofCore/Snippets/compiles intoTools/snippets-test.swift(the harness globs the directory), 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.Core/SystemCommand.swiftis also Foundation-only forTools/system-command-test.swift; platform effects belong inSystemCommandRunner, while confirmation and failure UI remain inAppCore.Core/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.Tools/fuzz-test.swiftholds a COPY ofFuzzyMatchfromCore/AppIndex.swift. Change the scoring in one, mirror it in the other, or the test is meaningless.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. - 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. See hotkeys.md. - Tinycast presents its own dialogs, never
NSAlert/NSSlider/ system popovers. Every confirmation, failure report and value prompt goes throughModalWindowController(owned byAppCore; reachable elsewhere viaAppCore.showNotice/askConfirmation). 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's tone is one of fiveModalKindcases (.info/.success/.warning/.error/.custom(Color)), which drives its glyph's tint and default icon..warningis a confirmation before something happens;.erroris a report that something already went wrong. Don't conflate the two — even though both now share the same red tint (only the default icon's triangle-vs-circle shape tells them apart), the semantic split still governs which one a caller reaches for. A button never prints its key cap; hovering it shows aTooltip(Core/Tooltip.swift) instead, styled like the palette's own keycap chips. A transient readout is a HUD, not a dialog:ModalWindowController's square box is volume/mute only, since that one needs an actual level; every other success/info confirmation (system commands, Custom Commands, Snippets) goes throughHUDWindowController's pill, a leadingstatusDottinted by the sameModalKindstanding in for an icon. See ui.md. - Read
docs/ui.mdbefore any restyle or new view.Core/Theme.swiftis the single design-token source. Core/EdgeDissolve.swiftandCore/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/Core/— managers, stores, windows, AppKit glue (no view bodies beyond hosting).Core/Calculator/andCore/Emoji/are Foundation-only engines;Core/Snippets/is a standalone-harness input in full;Core/WindowManagement/is a pure geometry layer plus its one AX file;Core/Theme.swiftis the design-token source;Core/HotKey/is the in-house hotkey stack.Tinycast/Features/— SwiftUI views:RootPaletteView,Launcher/,Clipboard/,Calculator/,Emoji/,Settings/,About/,Onboarding/, plus sharedPopoverMenu.Tinycast/App/—@mainapp + delegate.Tools/— standalone test harnesses and the emoji generator..github/workflows/release.yml— the entire release pipeline (seedocs/development.md).
Additional Documentation
docs/architecture.md— core ownership, windows, concurrency.docs/palette.md— palette state flow, menu-open freeze, focus restoration.docs/launcher.md·docs/calculator.md·docs/clipboard.md·docs/emoji.md·docs/snippets.md·docs/window-management.md·docs/hotkeys.md— subsystem internals.docs/ui.md— the full visual design system, tokens, scrollbars, section headers.docs/development.md— build, test, package, release.docs/signing.md— signing model and Gatekeeper.