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 screen edges 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>

    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>
    ) {
        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
    }
}

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, stageManager, macOSTiling, fourFingerGesture
        case 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.
    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"]),
    ]

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

    public static func evaluate(_ observation: SetupObservation) -> [SetupCheck] {
        [
            SetupCheck(
                id: .accessibility, title: "Accessibility permission",
                status: observation.accessibilityTrusted
                    ? .passed
                    : .problem(
                        "Turn on Miri in System Settings > Privacy & Security > Device Control and Data Access. Miri needs this Accessibility permission to move windows."
                    ),
                blocksStart: true, settingsURL: accessibilityURL),
            SetupCheck(
                id: .separateSpaces, title: "Displays have separate Spaces",
                status: spacesStatus(observation),
                blocksStart: true, settingsURL: desktopURL),
            SetupCheck(
                id: .competingManagers, title: "Other window managers",
                status: competingStatus(observation), blocksStart: true, settingsURL: nil),
            SetupCheck(
                id: .stageManager, title: "Stage Manager",
                status: stageManagerStatus(observation),
                blocksStart: false, settingsURL: desktopURL),
            SetupCheck(
                id: .macOSTiling, title: "macOS window tiling",
                status: tilingStatus(observation),
                blocksStart: false, settingsURL: desktopURL),
            SetupCheck(
                id: .fourFingerGesture, title: "Four-finger strip swipe",
                status: fourFingerStatus(observation),
                blocksStart: false, settingsURL: trackpadURL),
            SetupCheck(
                id: .threeFingerGesture, title: "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("Quit \(names) before starting Miri. Miri leaves \(names)'s configuration untouched.")
    }

    private static func spacesStatus(_ observation: SetupObservation) -> SetupStatus {
        switch observation.spansDisplays {
        case false?: .passed
        case true?:
            .problem(
                "Turn on Displays have separate Spaces in System Settings > Desktop & Dock > Mission Control, then log out and back in."
            )
        case nil:
            .notChecked(
                "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("Turn off Stage Manager in System Settings > Desktop & Dock. It rearranges windows.")
        case nil: .notChecked("Open System Settings > Desktop & Dock and make sure Stage Manager is off.")
        }
    }

    private static func tilingStatus(_ observation: SetupObservation) -> SetupStatus {
        let options = [
            (observation.edgeTiling, "Drag windows to screen edges to tile"),
            (observation.menuBarTiling, "Drag windows to menu bar to fill screen"),
            (observation.optionDragTiling, "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(
                "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(
                "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(
                "Four-finger swipes switch full-screen apps. Change Swipe between full-screen applications in System Settings > Trackpad > More Gestures. Turning it off also stops swiping between desktops. Setting it to three fingers keeps swiping between desktops and leaves four fingers for the strip."
            )
        default: gestureUnverified(fingers: "four")
        }
    }

    private static func threeFingerStatus(_ observation: SetupObservation) -> SetupStatus {
        if observation.threeFingerDrag == true {
            return .problem(
                "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(
                "Three-finger swipes navigate between pages. Set Swipe between pages to two fingers in System Settings > Trackpad > More Gestures."
            )
        case 2?:
            return .problem(
                "Three-finger swipes switch full-screen apps. Set Swipe between full-screen applications to four fingers in System Settings > Trackpad > More Gestures."
            )
        default: return gestureUnverified(fingers: "three")
        }
    }

    private static func gestureUnverified(fingers: String) -> SetupStatus {
        .unableToVerify(
            "Miri could not read the macOS \(fingers)-finger swipe settings. Use Test Gesture in the Gestures tab to check the swipe."
        )
    }
}