Sources/MiriCore/SetupChecker.swift
import Foundation
/// What the setup checker observed. `nil` means the value could not be read.
public struct SetupObservation: Equatable, Sendable {
public var accessibilityTrusted: Bool
/// `com.apple.spaces` `spans-displays`. `true` means **Displays have separate Spaces** is off.
public var spansDisplays: Bool?
/// `com.apple.WindowManager` `GloballyEnabled`.
public var stageManager: Bool?
/// `TrackpadThreeFingerHorizSwipeGesture`: 0 off, 1 swipe between pages, 2 swipe between full-screen apps.
public var threeFingerSwipe: Int?
/// `TrackpadFourFingerHorizSwipeGesture`: 0 off, 2 swipe between full-screen apps.
public var fourFingerSwipe: Int?
public var threeFingerDrag: Bool?
/// `com.apple.WindowManager` `EnableTilingByEdgeDrag`: **Drag windows to left or right edge of screen to tile**.
public var edgeTiling: Bool?
/// `com.apple.WindowManager` `EnableTopTilingByEdgeDrag`: **Drag windows to menu bar to fill screen**.
public var menuBarTiling: Bool?
/// `com.apple.WindowManager` `EnableTilingOptionAccelerator`: **Hold ⌥ key while dragging windows to tile**.
public var optionDragTiling: Bool?
public var runningBundleIdentifiers: Set<String>
public var runningProcessNames: Set<String>
/// The major macOS version, such as 15 or 27. System Settings names the Accessibility pane differently by version.
public var macOSMajorVersion: Int
public init(
accessibilityTrusted: Bool, spansDisplays: Bool?, stageManager: Bool?, threeFingerSwipe: Int?,
fourFingerSwipe: Int?, threeFingerDrag: Bool?, edgeTiling: Bool?, menuBarTiling: Bool?,
optionDragTiling: Bool?, runningBundleIdentifiers: Set<String>, runningProcessNames: Set<String>,
macOSMajorVersion: Int
) {
self.accessibilityTrusted = accessibilityTrusted
self.spansDisplays = spansDisplays
self.stageManager = stageManager
self.threeFingerSwipe = threeFingerSwipe
self.fourFingerSwipe = fourFingerSwipe
self.threeFingerDrag = threeFingerDrag
self.edgeTiling = edgeTiling
self.menuBarTiling = menuBarTiling
self.optionDragTiling = optionDragTiling
self.runningBundleIdentifiers = runningBundleIdentifiers
self.runningProcessNames = runningProcessNames
self.macOSMajorVersion = macOSMajorVersion
}
}
public enum SetupStatus: Equatable, Sendable {
case passed
/// A problem the user can fix. The text says what to change.
case problem(String)
/// Miri could not inspect the setting. The text gives manual instructions.
case notChecked(String)
/// Gesture conflict detection was unavailable. Miri offers a gesture test instead.
case unableToVerify(String)
}
public struct SetupCheck: Equatable, Sendable, Identifiable {
public enum Kind: String, Sendable, CaseIterable {
case accessibility, separateSpaces, competingManagers, snappingApps, stageManager, macOSTiling
case fourFingerGesture, threeFingerGesture
}
public let id: Kind
public let title: String
public let status: SetupStatus
/// When the check has a problem, management cannot start.
public let blocksStart: Bool
public let settingsURL: URL?
public var isBlocking: Bool {
if case .problem = status { blocksStart } else { false }
}
}
public enum SetupChecker {
/// Known window managers that fight Miri for window positions, by bundle identifier and by process name.
// Not interface text: app names stay as the apps spell them.
public static let competingManagers: [(name: String, bundleIdentifiers: Set<String>, processNames: Set<String>)] = [
("Paneru", ["com.github.karinushka.paneru", "com.github.karinushka.paneru.launcher"], ["paneru"]),
("AeroSpace", ["bobko.aerospace"], ["AeroSpace"]),
("Amethyst", ["com.amethyst.Amethyst"], []),
("yabai", [], ["yabai"]),
]
/// Apps that move a window when you use one of their window actions, by bundle identifier. Each identifier comes
/// from the app's signed bundle.
// Not interface text: app names stay as the apps spell them.
public static let snappingApps: [(name: String, bundleIdentifiers: Set<String>)] = [
("Rectangle", ["com.knollsoft.Rectangle"]),
("Loop", ["com.MrKai77.Loop"]),
("Magnet", ["com.crowdcafe.windowmagnet"]),
]
static let accessibilityURL = URL(
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
static let desktopURL = URL(string: "x-apple.systempreferences:com.apple.Desktop-Settings.extension")
static let trackpadURL = URL(string: "x-apple.systempreferences:com.apple.Trackpad-Settings.extension")
/// The pane under Privacy & Security that holds the Accessibility permission. macOS 27 renamed it.
static func accessibilityPane(_ observation: SetupObservation) -> String {
observation.macOSMajorVersion >= 27
? String(localized: "Device Control and Data Access") : String(localized: "Accessibility")
}
public static func evaluate(_ observation: SetupObservation) -> [SetupCheck] {
[
SetupCheck(
id: .accessibility, title: String(localized: "Accessibility permission"),
status: observation.accessibilityTrusted
? .passed
: .problem(
String(
localized:
"Turn on Miri in System Settings > Privacy & Security > \(accessibilityPane(observation)). Miri needs this Accessibility permission to move windows."
)
),
blocksStart: true, settingsURL: accessibilityURL),
SetupCheck(
id: .separateSpaces, title: String(localized: "Displays have separate Spaces"),
status: spacesStatus(observation),
blocksStart: true, settingsURL: desktopURL),
SetupCheck(
id: .competingManagers, title: String(localized: "Other window managers"),
status: competingStatus(observation), blocksStart: true, settingsURL: nil),
SetupCheck(
id: .snappingApps, title: String(localized: "Window-snapping apps"),
status: snappingStatus(observation), blocksStart: false, settingsURL: nil),
SetupCheck(
id: .stageManager, title: String(localized: "Stage Manager"),
status: stageManagerStatus(observation),
blocksStart: false, settingsURL: desktopURL),
SetupCheck(
id: .macOSTiling, title: String(localized: "macOS window tiling"),
status: tilingStatus(observation),
blocksStart: false, settingsURL: desktopURL),
SetupCheck(
id: .fourFingerGesture, title: String(localized: "Four-finger strip swipe"),
status: fourFingerStatus(observation),
blocksStart: false, settingsURL: trackpadURL),
SetupCheck(
id: .threeFingerGesture, title: String(localized: "Three-finger strip swipe"),
status: threeFingerStatus(observation), blocksStart: false, settingsURL: trackpadURL),
]
}
/// The names of running competing window managers.
public static func runningCompetitors(_ observation: SetupObservation) -> [String] {
competingManagers.filter {
!$0.bundleIdentifiers.isDisjoint(with: observation.runningBundleIdentifiers)
|| !$0.processNames.isDisjoint(with: observation.runningProcessNames)
}.map(\.name)
}
private static func competingStatus(_ observation: SetupObservation) -> SetupStatus {
let running = runningCompetitors(observation)
guard !running.isEmpty else { return .passed }
let names = ListFormatter.localizedString(byJoining: running)
return .problem(
String(localized: "Quit \(names) before starting Miri. Miri leaves \(names)'s configuration untouched."))
}
private static func snappingStatus(_ observation: SetupObservation) -> SetupStatus {
let running = snappingApps.filter {
!$0.bundleIdentifiers.isDisjoint(with: observation.runningBundleIdentifiers)
}
guard !running.isEmpty else { return .passed }
let names = ListFormatter.localizedString(byJoining: running.map(\.name))
return .problem(
String(
localized:
"\(names) can move a window that Miri arranges, and the window's column can then change place in the strip. Quit \(names) while Miri manages windows."
)
)
}
private static func spacesStatus(_ observation: SetupObservation) -> SetupStatus {
switch observation.spansDisplays {
case false?: .passed
case true?:
.problem(
String(
localized:
"Turn on Displays have separate Spaces in System Settings > Desktop & Dock > Mission Control, then log out and back in."
)
)
case nil:
.notChecked(
String(
localized:
"Open System Settings > Desktop & Dock > Mission Control and make sure Displays have separate Spaces is on."
)
)
}
}
private static func stageManagerStatus(_ observation: SetupObservation) -> SetupStatus {
switch observation.stageManager {
case false?: .passed
case true?:
.problem(
String(localized: "Turn off Stage Manager in System Settings > Desktop & Dock. It rearranges windows."))
case nil:
.notChecked(String(localized: "Open System Settings > Desktop & Dock and make sure Stage Manager is off."))
}
}
private static func tilingStatus(_ observation: SetupObservation) -> SetupStatus {
let options = [
(observation.edgeTiling, String(localized: "Drag windows to left or right edge of screen to tile")),
(observation.menuBarTiling, String(localized: "Drag windows to menu bar to fill screen")),
(observation.optionDragTiling, String(localized: "Hold ⌥ key while dragging windows to tile")),
]
let on = options.filter { $0.0 == true }.map(\.1)
let unread = options.filter { $0.0 == nil }.map(\.1)
if !on.isEmpty {
return .problem(
String(
localized:
"Turn off \(ListFormatter.localizedString(byJoining: on)) in System Settings > Desktop & Dock > Windows. macOS tiling moves windows that Miri arranges, including windows you drag."
)
)
}
if !unread.isEmpty {
return .notChecked(
String(
localized:
"Open System Settings > Desktop & Dock > Windows and make sure \(ListFormatter.localizedString(byJoining: unread)) is off."
)
)
}
return .passed
}
private static func fourFingerStatus(_ observation: SetupObservation) -> SetupStatus {
// macOS keeps the four-finger value at 2 while full-screen swiping uses three fingers, so 2 is a conflict
// only when the three-finger swipe is not the one switching full-screen apps.
switch (observation.fourFingerSwipe, observation.threeFingerSwipe) {
case (0?, _), (2?, 2?): .passed
case (2?, _?):
.problem(
String(
localized:
"Four-finger swipes switch full-screen apps. Set Swipe between full-screen applications to three fingers in System Settings > Trackpad > More Gestures, which keeps swiping between desktops. Turning it off also stops swiping between desktops."
)
)
default:
.unableToVerify(
String(
localized:
"Miri could not read the macOS four-finger swipe settings. Use Test Gesture on the Gestures page to check the swipe."
)
)
}
}
private static func threeFingerStatus(_ observation: SetupObservation) -> SetupStatus {
if observation.threeFingerDrag == true {
return .problem(
String(
localized:
"Three Finger Drag is on. Turn it off in System Settings > Accessibility > Pointer Control > Trackpad Options, or use four fingers."
)
)
}
switch observation.threeFingerSwipe {
case 0? where observation.threeFingerDrag == false: return .passed
case 1?:
return .problem(
String(
localized:
"Three-finger swipes navigate between pages. Set Swipe between pages to two fingers in System Settings > Trackpad > More Gestures."
)
)
case 2?:
return .problem(
String(
localized:
"Three-finger swipes switch full-screen apps. Set Swipe between full-screen applications to four fingers in System Settings > Trackpad > More Gestures."
)
)
default:
// Each finger count is a whole sentence, so a translation never shows an English number word.
return .unableToVerify(
String(
localized:
"Miri could not read the macOS three-finger swipe settings. Use Test Gesture on the Gestures page to check the swipe."
)
)
}
}
}