Files
tinycast/Tinycast/App/AppCore.swift
T
2026-08-09 00:38:33 +06:00

313 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
/// Single owner of every long-lived manager. Wired up once from the app delegate.
@MainActor
@Observable
final class AppCore {
static let shared = AppCore()
let launcherRanking: LauncherRankingStore
let appIndex: AppIndex
let customCommands = CustomCommandStore()
let quicklinks = QuicklinkStore()
let clipboardStore = ClipboardStore()
let clipboardManager: ClipboardManager
let snippetsStore: SnippetsStore
let snippetListener = SnippetKeywordListener(
syntheticEventTag: Paster.tinycastEventTag)
let snippetTextInjector: SnippetTextInjector
let hotKeys = HotKeyManager()
let hyperKeyTap = HyperKeyTap()
let windowMover = WindowMover()
let settings: AppSettings
let favorites = FavoritesStore()
let visibility = VisibilityStore()
let calcHistory = CalculatorHistoryStore()
let currencyRates = CurrencyRateStore()
let emojiIndex = EmojiIndex()
let frequentEmoji = FrequentEmojiStore()
let runningApps = RunningAppsMonitor()
let palette = PaletteState()
let activationPolicy = ActivationPolicy()
let uninstall = UninstallSession()
let quicklinkArguments = QuicklinkArgumentSession()
/// Set when a quicklink editor should open with Settings; the pane consumes it.
var pendingQuicklinkEdit: QuicklinkEditRequest?
@ObservationIgnored private(set) lazy var snippetExpansion = SnippetExpansionCoordinator(
store: snippetsStore, listener: snippetListener, injector: snippetTextInjector,
clipboardStore: clipboardStore, appIndex: appIndex, settings: settings,
showMessage: { [unowned self] in self.showMessage($0) }, core: self)
@ObservationIgnored private(set) lazy var quicklinkCoordinator = QuicklinkCoordinator(
store: quicklinks, argumentSession: quicklinkArguments, settings: settings,
appIndex: appIndex, injector: snippetTextInjector, hotKeys: hotKeys, favorites: favorites,
visibility: visibility, ranking: launcherRanking, windowController: windowController,
paletteCoordinator: paletteCoordinator, settingsCoordinator: settingsCoordinator,
clipboardHistory: { [unowned self] in self.snippetExpansion.clipboardHistoryForExpansion() },
core: self)
@ObservationIgnored private(set) lazy var paletteCoordinator = PaletteCoordinator(
palette: palette, settings: settings, appIndex: appIndex,
windowController: windowController)
/// Its own window and its own lifecycle: neither coordinator shows or closes the other's surface.
@ObservationIgnored private(set) lazy var settingsCoordinator = SettingsCoordinator(core: self)
@ObservationIgnored private(set) lazy var onboardingCoordinator = OnboardingCoordinator(
core: self)
@ObservationIgnored private(set) lazy var systemActionCoordinator = SystemActionCoordinator(
paletteCoordinator: paletteCoordinator, core: self)
@ObservationIgnored private(set) lazy var uninstallCoordinator = UninstallCoordinator(
session: uninstall, palette: palette, paletteCoordinator: paletteCoordinator,
appIndex: appIndex, runningApps: runningApps, hotKeys: hotKeys, favorites: favorites,
visibility: visibility, ranking: launcherRanking, core: self)
@ObservationIgnored private(set) lazy var windowCommandCoordinator = WindowCommandCoordinator(
settings: settings, paletteCoordinator: paletteCoordinator, windowMover: windowMover)
@ObservationIgnored private(set) lazy var customCommandCoordinator = CustomCommandCoordinator(
store: customCommands, settings: settings, appIndex: appIndex,
paletteCoordinator: paletteCoordinator, settingsCoordinator: settingsCoordinator,
hotKeys: hotKeys, favorites: favorites, visibility: visibility,
ranking: launcherRanking, core: self)
@ObservationIgnored private(set) lazy var launcherCoordinator = LauncherCoordinator(
ranking: launcherRanking, windowController: windowController,
paletteCoordinator: paletteCoordinator,
settingsCoordinator: settingsCoordinator,
customCommandCoordinator: customCommandCoordinator,
systemActionCoordinator: systemActionCoordinator,
quicklinkCoordinator: quicklinkCoordinator,
windowCommandCoordinator: windowCommandCoordinator,
snippetExpansion: snippetExpansion, core: self)
@ObservationIgnored private(set) lazy var clipboardCoordinator = ClipboardCoordinator(
clipboardStore: clipboardStore, palette: palette, windowController: windowController,
paletteCoordinator: paletteCoordinator)
@ObservationIgnored private(set) lazy var emojiCoordinator = EmojiCoordinator(
frequentEmoji: frequentEmoji, settings: settings, windowController: windowController,
paletteCoordinator: paletteCoordinator)
@ObservationIgnored private(set) lazy var calculatorCoordinator = CalculatorCoordinator(
calcHistory: calcHistory, paletteCoordinator: paletteCoordinator)
@ObservationIgnored private lazy var windowController = PaletteWindowController(core: self)
@ObservationIgnored private lazy var messageHUD = MessageHUDController(settings: settings)
/// Every confirmation, report and prompt; it also stops a held hotkey stacking them.
private let dialogs = DialogController()
private let healthTicker = HealthTicker()
private init() {
let launcherRanking = LauncherRankingStore()
let settings = AppSettings()
self.launcherRanking = launcherRanking
self.settings = settings
appIndex = AppIndex(ranking: launcherRanking)
let clipboardManager = ClipboardManager(store: clipboardStore, settings: settings)
self.clipboardManager = clipboardManager
snippetsStore = SnippetsStore()
snippetTextInjector = SnippetTextInjector(
clipboardManager: clipboardManager,
settings: settings)
}
func start() {
Signposts.interval("AppCore.start") {
// Shorten AppKit's ~2–3s tooltip delay; registration domain, so a user default wins.
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 250])
NSApp.setActivationPolicy(.accessory)
// Force dark: the Liquid Glass material is tuned for a deep dark surface.
NSApp.appearance = NSAppearance(named: .darkAqua)
clipboardStore.maxAge = settings.clipboardRetention.maxAge
// Defer the SQLite read + prune off the launch path; the palette fills in later.
Task { clipboardStore.load() }
clipboardManager.start()
appIndex.start(settings: settings)
customCommands.onChange = { [weak self] _ in
self?.customCommandCoordinator.applyCustomCommandsPresence()
}
customCommandCoordinator.applyCustomCommandsPresence()
applyWindowCommandsPresence()
quicklinks.onChange = { [weak self] _ in
self?.quicklinkCoordinator.applyQuicklinksPresence()
}
// Before `hotKeys.start` even when off: the prune reads it. docs/features/quicklinks.md
quicklinks.load()
quicklinkCoordinator.applyQuicklinksPresence()
Task { await appIndex.refresh() }
Task { await emojiIndex.load() }
currencyRates.start()
hyperKeyTap.healthTicker = healthTicker
hotKeys.doubleTapMonitor.healthTicker = healthTicker
snippetListener.healthTicker = healthTicker
hotKeys.onTogglePalette = { [weak self] in self?.paletteCoordinator.togglePalette() }
hotKeys.onToggleClipboard = { [weak self] in self?.paletteCoordinator.toggleClipboard() }
hotKeys.onToggleEmoji = { [weak self] in self?.paletteCoordinator.toggleEmoji() }
hotKeys.onRunCustomCommand = { [weak self] id in
self?.customCommandCoordinator.runCustomCommand(id: id)
}
hotKeys.onRunSystemAction = { [weak self] id in
self?.systemActionCoordinator.runSystemAction(id: id)
}
hotKeys.onRunWindowCommand = { [weak self] id in
self?.windowCommandCoordinator.runWindowCommand(id: id)
}
hotKeys.onOpenQuicklink = { [weak self] id in
self?.quicklinkCoordinator.openQuicklink(id: id)
}
hotKeys.displayName = { [weak self] action in self?.hotKeyDisplayName(for: action) }
KeyShortcut.displayedHyperChord = { [settings] in
guard settings.hyperKey != .none else { return nil }
return KeyShortcut.hyperChord(includesShift: settings.hyperKeyIncludesShift)
}
SystemActionRunner.onAsyncFailure = { [weak self] id, failure in
self?.systemActionCoordinator.presentSystemActionFailure(id: id, failure: failure)
}
hotKeys.start(
customCommandIDs: Set(customCommands.commands.map(\.id)),
quicklinkIDs: Set(quicklinks.quicklinks.map(\.id)))
// Keeps running while Carbon pauses: the recorder needs its rewritten flags.
hyperKeyTap.start(settings: settings)
snippetsStore.onSnapshot = { [weak self] snapshot in
guard let self else { return }
self.snippetExpansion.applySnippetsLauncherPresence()
self.snippetListener.update(snapshot.records)
}
// Off out of the box, so an unused feature costs no load, watcher or tap.
if settings.snippetsEnabled {
Task { await snippetsStore.start() }
snippetExpansion.startSnippetKeywordListener()
}
observeFeatureSwitches()
// First launch binds no hotkey, so guide once; the marker is written at show-time.
if !OnboardingState.hasOnboarded {
OnboardingState.markShown()
onboardingCoordinator.showOnboarding()
}
}
}
/// Clicking the Dock icon: raise whichever window is already open, else summon the launcher.
func handleReopen() {
if settingsCoordinator.focusExisting() { return }
if onboardingCoordinator.focusExisting() { return }
paletteCoordinator.showPalette(mode: .launcher, restoreAnyMode: true)
}
/// The store-backed half of the conflict message; `HotKeyManager` names the catalogs itself.
private func hotKeyDisplayName(for action: HotKeyAction) -> String? {
switch action {
case .app(let bundleID):
return appIndex.apps.first { $0.kind == .application && $0.bundleID == bundleID }?.name
case .settingsPane(let bundleID):
return appIndex.apps.first { $0.kind == .systemSettings && $0.bundleID == bundleID }?
.name
case .customCommand(let id):
return customCommands.command(id: id)?.name
case .quicklink(let id):
return quicklinks.quicklink(id: id)?.name
case .togglePalette, .toggleClipboard, .toggleEmoji, .systemAction, .windowCommand:
return nil
}
}
func prepareForTermination() {
// Caps Lock first: its remap is the one teardown that outlives the process.
hyperKeyTap.prepareForTermination()
snippetTextInjector.prepareForTermination()
snippetListener.stop()
snippetsStore.stop()
}
// MARK: - Feature switches
private func observeFeatureSwitches() {
track(
{
_ = $0.windowManagementEnabled
_ = $0.windowManagementShowInLauncher
}, reproject: { $0.applyWindowCommandsPresence() })
track(
{
_ = $0.customCommandsEnabled
_ = $0.customCommandsShowInLauncher
}, reproject: { $0.customCommandCoordinator.applyCustomCommandsPresence() })
track(
{
_ = $0.quicklinksEnabled
_ = $0.quicklinksShowInLauncher
}, reproject: { $0.quicklinkCoordinator.applyQuicklinksPresence() })
track({ _ = $0.snippetsEnabled }, reproject: { $0.snippetExpansion.applySnippetsEnabled() })
// Not a feature switch, but the same re-projection: a combo has the chord's ⇧ bit baked in.
track({ _ = $0.hyperKeyIncludesShift }, reproject: { $0.applyHyperChord() })
track(
{ _ = $0.snippetsShowInLauncher },
reproject: { $0.snippetExpansion.applySnippetsLauncherPresence() })
}
/// Fires synchronously on main before the write lands, so the task re-arms and re-reads.
private func track(
_ reads: @escaping @Sendable @MainActor (AppSettings) -> Void,
reproject: @escaping @Sendable @MainActor (AppCore) -> Void
) {
withObservationTracking {
reads(settings)
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.track(reads, reproject: reproject)
reproject(self)
}
}
}
/// Without a Hyper key the chord means nothing, so a literal ⌃⌥⌘ combo is left as recorded.
private func applyHyperChord() {
guard settings.hyperKey != .none else { return }
hotKeys.retargetHyperBindings(includesShift: settings.hyperKeyIncludesShift)
}
private func applyWindowCommandsPresence() {
let visible = settings.windowManagementEnabled && settings.windowManagementShowInLauncher
appIndex.setWindowCommandsVisible(visible)
}
// MARK: - Dialogs, routed here so `dialogs` stays the single owner
func showNotice(title: String, message: String, symbol: String, tone: DialogTone) async {
await dialogs.notice(title: title, message: message, symbol: symbol, tone: tone)
}
/// `tone` styles the glyph, `confirmRole` the button; separate on purpose.
func confirm(
title: String, message: String, symbol: String, confirmTitle: String,
tone: DialogTone = .danger, confirmRole: DialogAction.Role = .destructive
) async -> Bool {
await dialogs.confirm(
title: title, message: message, symbol: symbol, tone: tone, confirmTitle: confirmTitle,
confirmRole: confirmRole)
}
/// A failure with one usable second option; `true` when the user takes it.
func reportFailure(
title: String, message: String, symbol: String, recovery: String?
) async
-> Bool
{
await dialogs.reportFailure(
title: title, message: message, symbol: symbol, recovery: recovery)
}
/// The transient success/info pill, so `messageHUD` stays single-owned alongside `dialogs`.
func showMessage(_ message: String, tone: DialogTone = .success) {
messageHUD.show(message: message, tone: tone)
}
/// The volume slider, so `dialogs` stays the single owner of every prompt in the app.
func pickVolume(current: Float32) async -> Float32? {
await dialogs.pickVolume(current: current)
}
}