Sources/Miri/AppModel.swift
import AppKit
import Carbon.HIToolbox
import MiriCore
import Observation
import ServiceManagement
/// The app's state: configuration, engine lifecycle, setup checks, and the saved stopped state.
@Observable final class AppModel {
static let stoppedKey = "managementStopped"
static let setupPollInterval: Duration = .seconds(2)
static let gestureVerificationKey = "gestureVerification"
static let initialSetupKey = "initialSetupDone"
/// How often Miri rereads the macOS gesture settings while a strip swipe is selected.
static let gesturePollInterval: Duration = .seconds(5)
let paths: EnginePaths
let log: RotatingLog
private(set) var engineState: EngineState = .stopped
/// The Dock icon follows every new value, from launch, a file edit, or Settings, so a login launch follows it too.
private(set) var settings = MiriSettings() {
didSet { showDockIcon(settings.showInDock) }
}
private(set) var configurationIssue: ConfigurationIssue?
/// The file's shortcuts, or `nil` while the file cannot be read correctly.
private(set) var shortcuts: Shortcuts?
/// The defaults the last upgrade could not move to their new shortcut.
private(set) var keptDefaults: [KeptDefault] = []
private(set) var keyLayout: KeyLayout
private(set) var setupChecks: [SetupCheck] = []
/// Why management is not running when the user did not stop it, such as missing permission.
private(set) var startBlockers: [SetupCheck] = []
private(set) var loginItemError: String?
/// The user's explicit Stop. It persists across launches until the user starts management again.
private(set) var stoppedByUser: Bool
/// The confirmed gesture test. It is app state, so editing the configuration file cannot mark a swipe as tested.
private(set) var gestureVerification: GestureVerification?
/// The macOS version, engine revision, and macOS gesture settings that a gesture test result depends on.
private(set) var gestureEnvironment: GestureEnvironment
/// The finger count a running gesture test turned on.
private(set) var gestureTestFingers: GestureFingers?
/// Miri turned the shared-edge switch off, or kept it off, because Screen Recording permission was missing.
private var screenRecordingRefused = false
@ObservationIgnored private let store: ConfigurationStore
@ObservationIgnored private var supervisor: EngineSupervisor!
@ObservationIgnored private var watcher: ConfigurationWatcher?
@ObservationIgnored private var setupPoll: Task<Void, Never>?
@ObservationIgnored private var gesturePoll: Task<Void, Never>?
@ObservationIgnored private var stopShortcut: StopShortcut?
@ObservationIgnored private let defaults = UserDefaults.standard
@ObservationIgnored private let screenRecording: any ScreenRecordingAccess = SystemScreenRecording()
init(bundle: Bundle = .main) {
let identifier = bundle.bundleIdentifier ?? "io.github.samanyudas.miri"
let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Miri" // Not interface text
let support = URL.applicationSupportDirectory.appending(path: name, directoryHint: .isDirectory)
paths = EnginePaths(
executable: bundle.bundleURL.appending(path: "Contents/Helpers/miri-engine"), support: support,
serviceName: identifier + ".engine")
log = RotatingLog(url: paths.logs.appending(path: "engine.log"))
let layout = KeyLayout.current
keyLayout = layout
store = ConfigurationStore(url: paths.configuration, appBundleIdentifier: identifier, layout: layout)
stoppedByUser = UserDefaults.standard.bool(forKey: Self.stoppedKey)
gestureVerification = UserDefaults.standard.data(forKey: Self.gestureVerificationKey).flatMap {
try? JSONDecoder().decode(GestureVerification.self, from: $0)
}
gestureEnvironment = GestureEnvironment(
macOSVersion: ProcessInfo.processInfo.operatingSystemVersionString,
engineRevision: Self.engineRevision(bundle: bundle),
gestureSettings: GestureSettingsSnapshot(threeFingerSwipe: nil, fourFingerSwipe: nil, threeFingerDrag: nil))
supervisor = EngineSupervisor(
launcher: ProcessEngineLauncher(paths: paths, log: log),
recoverWindows: { WindowRestorer.recover() })
supervisor.onChange = { [weak self] state in self?.engineState = state }
store.gestureFingers = { [weak self] settings in
MainActor.assumeIsolated { self?.activeGestureFingers(for: settings) }
}
}
var reduceMotion: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
/// Loads the configuration, watches it, and starts management unless the user stopped it.
func launch() {
stopOrphanedEngines()
stopShortcut = StopShortcut { [weak self] in self?.stopFromShortcut() }
// The first write of the file needs the gesture settings and conflicts, so the swipe stays on across launches.
refreshSetup()
reloadConfiguration()
watcher = ConfigurationWatcher(url: paths.configuration) { [weak self] in self?.reloadConfiguration() }
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in MainActor.assumeIsolated { self?.reloadConfiguration() } }
DistributedNotificationCenter.default().addObserver(
forName: Notification.Name(kTISNotifySelectedKeyboardInputSourceChanged as String), object: nil,
queue: .main
) { [weak self] _ in MainActor.assumeIsolated { self?.keyboardLayoutChanged() } }
syncLoginItem()
watchGestureSettings()
if !stoppedByUser { start() } else { refreshSetup() }
}
/// Stops engines that an earlier Miri left running when it died, then recovers the windows they managed.
private func stopOrphanedEngines() {
let orphans = OrphanedEngines.find()
guard !orphans.isEmpty else { return }
log.append(Data("--- Miri stopped engines left running without an app: \(orphans) ---\n".utf8))
OrphanedEngines.stop(orphans, timeout: EngineSupervisor.stopTimeout)
WindowRestorer.recover()
}
func reloadConfiguration() {
store.load(reduceMotion: reduceMotion)
settings = store.settings
configurationIssue = store.issue
shortcuts = store.shortcuts
keptDefaults = store.keptDefaults
stopShortcut?.register(store.stopChordsToRegister, layout: keyLayout)
dropStaleGestureVerification()
}
private func keyboardLayoutChanged() {
let layout = KeyLayout.current
guard layout != keyLayout else { return }
keyLayout = layout
store.layout = layout
reloadConfiguration()
// The engine reads the layout only when it starts, so it restarts to bind the same keys as Stop and the recorder.
if engineState.isRunning && !stoppedByUser { supervisor.restartManually() }
}
enum ShortcutAddOutcome {
case added
case rejected(ShortcutRejection)
case systemConflict(SystemShortcut)
/// The file could not be saved. `configurationIssue` says why.
case failed
}
/// Adds a shortcut unless another Miri binding uses its keys. A macOS conflict needs `allowingSystemConflict`.
func addShortcut(_ chord: Chord, to action: ShortcutAction, allowingSystemConflict: Bool = false)
-> ShortcutAddOutcome
{
guard let shortcuts else { return .failed }
if let rejection = shortcuts.rejection(adding: chord, to: action, layout: keyLayout) {
return .rejected(rejection)
}
if !allowingSystemConflict,
let conflict = SystemShortcuts.load(layout: keyLayout).conflict(for: chord, layout: keyLayout)
{
return .systemConflict(conflict)
}
return apply(.add(chord, to: action)) ? .added : .failed
}
func removeShortcut(_ chord: Chord, from action: ShortcutAction) {
apply(.remove(chord, from: action))
}
func resetShortcuts() {
apply(.resetToDefaults)
}
@discardableResult private func apply(_ edit: ShortcutEdit) -> Bool {
do {
try store.apply(edit)
configurationIssue = nil
shortcuts = store.shortcuts
keptDefaults = store.keptDefaults
stopShortcut?.register(store.stopChordsToRegister, layout: keyLayout)
return true
} catch {
let issue = error as? ConfigurationIssue ?? .notEditable(String(describing: error))
reloadConfiguration()
configurationIssue = configurationIssue ?? issue
return false
}
}
func update(_ change: (inout MiriSettings) -> Void) {
do {
try store.update(reduceMotion: reduceMotion, change)
settings = store.settings
configurationIssue = nil
dropStaleGestureVerification()
} catch {
configurationIssue = error
reloadConfiguration()
configurationIssue = configurationIssue ?? error
}
}
/// The app starts without a Dock icon, from `LSUIElement`, so a hidden icon never shows during a launch.
private func showDockIcon(_ shown: Bool) {
let policy: NSApplication.ActivationPolicy = shown ? .regular : .accessory
guard NSApp.activationPolicy() != policy else { return }
let wasActive = NSApp.isActive
NSApp.setActivationPolicy(policy)
// Hiding the icon from Settings must keep Settings in front. A file edit made in another app must not take focus.
if wasActive { NSApp.activate() }
}
func setLaunchAtLogin(_ enabled: Bool) {
update { $0.launchAtLogin = enabled }
syncLoginItem()
}
/// Whether Layout explains that the shared-edge switch is off for lack of Screen Recording permission.
var sharedEdgeSlicesNeedPermission: Bool { screenRecordingRefused && !settings.sharedEdgeSlices }
func setSharedEdgeSlices(_ wanted: Bool) {
let on = screenRecording.sharedEdgeSlices(wanting: wanted)
screenRecordingRefused = wanted && !on
update { $0.sharedEdgeSlices = on }
}
func refreshSetup() {
if screenRecording.revokes(sharedEdgeSlices: settings.sharedEdgeSlices) {
screenRecordingRefused = true
update { $0.sharedEdgeSlices = false }
}
let observation = SetupProbe.observe()
let checks = SetupChecker.evaluate(observation)
if checks != setupChecks { setupChecks = checks }
let gestureSettings = GestureSettingsSnapshot(observation)
if gestureSettings != gestureEnvironment.gestureSettings {
gestureEnvironment.gestureSettings = gestureSettings
}
dropStaleGestureVerification()
applyGestureFingers()
}
// MARK: Initial setup
/// Launch arguments win over saved defaults, so `-initialSetupDone NO` shows setup again after it was finished.
var needsInitialSetup: Bool { !defaults.bool(forKey: Self.initialSetupKey) }
func finishInitialSetup() {
defaults.set(true, forKey: Self.initialSetupKey)
}
/// Selects four fingers unless the user already chose a finger count. This alone does not turn the swipe on. Only
/// a confirmed gesture test, or a saved one that still matches, turns it on.
func recommendStripGesture() {
if settings.stripGesture == .off { update { $0.stripGesture = .four } }
}
// MARK: Strip gestures
var gestureState: GestureState { gestureState(for: settings) }
/// The setup check that reports macOS gestures clashing with a finger count, or `nil` before the first check.
func gestureCheck(for fingers: GestureFingers) -> SetupCheck? {
setupChecks.first { $0.id == fingers.setupCheck }
}
/// Why the gesture test cannot start, or `nil` when it can.
var gestureTestBlocker: String? {
guard engineState.isRunning else { return String(localized: "Start window management to test the swipe.") }
guard settings.stripGesture != .off else {
return String(localized: "Choose three or four fingers to test the swipe.")
}
if case .conflict = gestureState {
return String(localized: "Change the macOS gesture setting before testing the swipe.")
}
return nil
}
enum GestureTestResult {
/// The user confirmed the strip followed their fingers.
case confirmed(GestureVerification)
/// The test failed, was inconclusive, or the user said it did not work.
case failed
case cancelled
}
/// Turns on the selected swipe for a test. Returns the result to save if the user confirms it, dated now.
func beginGestureTest() throws(GestureTestBlocked) -> GestureVerification {
refreshSetup()
if let blocker = gestureTestBlocker { throw GestureTestBlocked(message: blocker) }
guard let fingers = settings.stripGesture.fingers else {
throw GestureTestBlocked(message: String(localized: "Choose three or four fingers to test the swipe."))
}
gestureTestFingers = fingers
applyGestureFingers()
return GestureVerification(
fingers: fingers, reversed: settings.reverseSwipe, environment: gestureEnvironment, date: .now)
}
/// Ends a gesture test. The swipe stays on only when the saved result turns it on.
func finishGestureTest(_ result: GestureTestResult) {
gestureTestFingers = nil
switch result {
case .confirmed(let verification): saveGestureVerification(verification)
case .failed: saveGestureVerification(nil)
case .cancelled: applyGestureFingers()
}
}
/// The user says a relevant macOS gesture changed, so the last test no longer counts.
func reportGestureChange() {
saveGestureVerification(nil)
}
/// A change that needs a new test also ends the old result, so changing back cannot turn the swipe on untested.
/// A conflict is ignored here, because it blocks the swipe without saying whether the tested setup changed. While
/// the swipe is Off, the tested finger count stands in for it, so other changes made while Off still count.
private func dropStaleGestureVerification() {
guard let verification = gestureVerification else { return }
let tested: StripGesture = verification.fingers == .three ? .three : .four
guard
case .needsTest = GestureState.evaluate(
preference: settings.stripGesture == .off ? tested : settings.stripGesture,
reversed: settings.reverseSwipe, verification: verification, environment: gestureEnvironment,
conflict: .passed)
else { return }
saveGestureVerification(nil)
}
private func saveGestureVerification(_ verification: GestureVerification?) {
gestureVerification = verification
if let verification, let data = try? JSONEncoder().encode(verification) {
defaults.set(data, forKey: Self.gestureVerificationKey)
} else {
defaults.removeObject(forKey: Self.gestureVerificationKey)
}
applyGestureFingers()
}
private func gestureState(for settings: MiriSettings) -> GestureState {
let conflict =
settings.stripGesture.fingers.flatMap { gestureCheck(for: $0)?.status }
?? .unableToVerify(String(localized: "Miri has not checked the macOS gesture settings yet."))
return GestureState.evaluate(
preference: settings.stripGesture, reversed: settings.reverseSwipe, verification: gestureVerification,
environment: gestureEnvironment, conflict: conflict)
}
private func activeGestureFingers(for settings: MiriSettings) -> GestureFingers? {
if let gestureTestFingers { return gestureTestFingers }
return gestureState(for: settings).isEnabled ? settings.stripGesture.fingers : nil
}
private func applyGestureFingers() {
do {
try store.applyGestureFingers(reduceMotion: reduceMotion)
} catch {
configurationIssue = error
}
}
/// Rereads the macOS gesture settings while a swipe is selected, so a change made in System Settings turns the
/// swipe off without waiting for a relaunch.
private func watchGestureSettings() {
gesturePoll = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.gesturePollInterval)
guard let self else { return }
if self.settings.stripGesture != .off { self.refreshSetup() }
}
}
}
/// Starts management, or waits and rechecks while a setup problem blocks it.
func start() {
stoppedByUser = false
defaults.set(false, forKey: Self.stoppedKey)
refreshSetup()
startBlockers = setupChecks.filter(\.isBlocking)
guard startBlockers.isEmpty else {
if !engineState.isRunning { supervisor.stop() }
pollSetupUntilReady()
return
}
setupPoll?.cancel()
setupPoll = nil
if engineState.isRunning { return }
reloadConfiguration()
supervisor.restartManually()
}
func stop() {
stoppedByUser = true
defaults.set(true, forKey: Self.stoppedKey)
cancelSetupPoll()
supervisor.stop()
}
func stopFromShortcut() {
guard !stoppedByUser else { return }
stop()
}
func restart() {
stoppedByUser = false
defaults.set(false, forKey: Self.stoppedKey)
if engineState.isRunning { supervisor.restartManually() } else { start() }
}
/// Stops the engine and recovers windows before the app exits. It does not change the saved stopped state.
func prepareToQuit(completion: @escaping () -> Void) {
cancelSetupPoll()
supervisor.stop(completion: completion)
}
func diagnostics() -> String {
refreshSetup()
let info = Bundle.main.infoDictionary ?? [:]
let version =
"\(info["CFBundleShortVersionString"] as? String ?? "?") (\(info["CFBundleVersion"] as? String ?? "?"))"
let revision = gestureEnvironment.engineRevision
let main = NSScreen.screens.first
let displays = NSScreen.screens.map { screen in
DiagnosticsReport.Display(
width: Int(screen.frame.width), height: Int(screen.frame.height), scale: screen.backingScaleFactor,
x: Int(screen.frame.minX), y: Int((main?.frame.maxY ?? 0) - screen.frame.maxY), isMain: screen == main)
}
let report = DiagnosticsReport(
appVersion: version, engineRevision: revision,
macOSVersion: ProcessInfo.processInfo.operatingSystemVersionString, architecture: "arm64",
displays: displays, engineState: engineState, setupChecks: setupChecks,
configurationIssue: configurationIssue, log: log.tail(maximum: 16_384))
let redactor = Redactor(
homeDirectory: NSHomeDirectory(), userNames: [NSUserName(), NSFullUserName()],
sensitiveStrings: WindowRestorer.openWindowTitles())
return report.rendered(with: redactor)
}
private static func engineRevision(bundle: Bundle) -> String {
let url = bundle.bundleURL.appending(path: "Contents/Resources/engine-revision.txt")
return (try? String(contentsOf: url, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines)
?? "unknown"
}
private func pollSetupUntilReady() {
guard setupPoll == nil else { return }
setupPoll = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.setupPollInterval)
guard let self, !Task.isCancelled, !self.stoppedByUser else { return }
self.refreshSetup()
self.startBlockers = self.setupChecks.filter(\.isBlocking)
if self.startBlockers.isEmpty {
self.setupPoll = nil
self.start()
return
}
}
}
}
private func cancelSetupPoll() {
setupPoll?.cancel()
setupPoll = nil
startBlockers = []
}
/// Whether this copy of Miri runs from an Applications folder, where macOS can register it as a login item.
static var isInApplicationsFolder: Bool {
let applications = FileManager.default.urls(for: .applicationDirectory, in: .allDomainsMask)
return applications.contains { Bundle.main.bundleURL.path.hasPrefix($0.path + "/") }
}
/// Registers the login item only from an Applications folder, so a build in a work folder never adds one.
private func syncLoginItem() {
let service = SMAppService.mainApp
guard Self.isInApplicationsFolder else {
loginItemError = String(
localized: "Launch at login takes effect after you move Miri to the Applications folder.")
return
}
do {
if settings.launchAtLogin, service.status != .enabled {
try service.register()
} else if !settings.launchAtLogin, service.status == .enabled {
try service.unregister()
}
loginItemError = nil
} catch {
loginItemError = String(localized: "Launch at login could not be changed. \(error.localizedDescription)")
}
}
}
struct GestureTestBlocked: Error {
let message: String
}
/// Calls `onChange` after the file is written, replaced, or deleted. It reopens the file after atomic replacement.
final class ConfigurationWatcher {
private let url: URL
private let onChange: () -> Void
private var source: DispatchSourceFileSystemObject?
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.onChange = onChange
watch()
}
deinit { source?.cancel() }
private func watch() {
source?.cancel()
let descriptor = open(url.path, O_EVTONLY)
guard descriptor >= 0 else {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in self?.watch() }
return
}
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor, eventMask: [.write, .extend, .delete, .rename], queue: .main)
source.setEventHandler { [weak self] in
guard let self else { return }
let replaced = !source.data.isDisjoint(with: [.delete, .rename])
// Let the writer finish before reading.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if replaced { self.watch() }
self.onChange()
}
}
source.setCancelHandler { close(descriptor) }
source.resume()
self.source = source
}
}