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."
                )
            )
        }
    }
}