beatmatchr/Desktop/YabaiPro/Sources/SettingsViewModel.swift
BusyBee3333 7694d965c9 feat: Add structured signal editor with app dropdown and action builder
- Add AppDiscovery provider for running app enumeration
- Implement AppDropdownView with auto-launch functionality
- Create SignalAction models for 40+ yabai commands
- Build ActionBuilderView with nested parameter controls
- Add LiveShellPreview for real-time shell command generation
- Implement ActionValidator for conflict detection
- Add migration parser for existing raw action strings
- Include feature flag for safe rollout
- Maintain full backward compatibility
2025-12-31 01:44:13 -05:00

577 lines
22 KiB
Swift

//
// SettingsViewModel.swift
// YabaiPro
//
// Created by Jake Shore
// Copyright © 2024 Jake Shore. All rights reserved.
//
import SwiftUI
import Combine
private let scriptingAdditionManager = YabaiScriptingAdditionManager.shared
enum Preset {
case `default`
case minimalist
}
// MARK: - Layout Enums
enum LayoutType: String, CaseIterable {
case bsp, stack, float
}
enum SplitType: String, CaseIterable {
case vertical, horizontal, auto
}
enum WindowPlacement: String, CaseIterable {
case first_child, second_child
var displayName: String {
switch self {
case .first_child: return "First Child"
case .second_child: return "Second Child"
}
}
}
struct GlobalPadding {
let top: Double
let bottom: Double
let left: Double
let right: Double
}
// MARK: - Mouse & Focus Enums
enum FocusMode: String, CaseIterable {
case off, autofocus, autoraise
}
enum WindowOriginDisplay: String, CaseIterable {
case `default`, focused, cursor
}
enum MouseModifier: String, CaseIterable {
case fn, cmd, alt, ctrl, shift
}
enum MouseAction: String, CaseIterable {
case move, resize
}
enum MouseDropAction: String, CaseIterable {
case swap, stack
}
enum AnimationEasing: String, CaseIterable {
case linear, easeIn, easeOut, easeInOut, easeOutCubic, easeInOutCubic
var displayName: String {
switch self {
case .linear: return "Linear"
case .easeIn: return "Ease In"
case .easeOut: return "Ease Out"
case .easeInOut: return "Ease In/Out"
case .easeOutCubic: return "Ease Out Cubic"
case .easeInOutCubic: return "Ease In/Out Cubic"
}
}
}
class SettingsViewModel: ObservableObject {
// Aesthetics
@Published var fadeInactiveWindows = false {
didSet {
updateSIPWarning()
checkScriptingAdditionStatus()
}
}
@Published var inactiveWindowOpacity: Double = 0.9
private var appliedInactiveWindowOpacity: Double = 0.9
@Published var disableShadows = false {
didSet {
updateSIPWarning()
checkScriptingAdditionStatus()
}
}
@Published var useSelectiveTransparency = false {
didSet {
Task {
if !useSelectiveTransparency {
// Reset to normal mode asynchronously
await SystemTransparencyManager.shared.disableSelectiveTransparency()
}
updateSIPWarning()
}
}
}
@Published var menuBarOpacity: Double = 1.0 {
didSet { updateSIPWarning() }
}
@Published var menuBarBackgroundOpacity: Double = 1.0 {
didSet { updateSIPWarning() }
}
@Published var menuBarIconOpacity: Double = 1.0 {
didSet { updateSIPWarning() }
}
@Published var windowGap: Double = 6.0
// Focus & Behavior
@Published var focusFollowsMouse = false
// Layout Configuration
@Published var layoutType: LayoutType = .bsp
@Published var splitRatio: Double = 0.5
@Published var splitType: SplitType = .auto
@Published var autoBalance: Bool = false
@Published var windowPlacement: WindowPlacement = .first_child
// Padding & Spacing
@Published var globalPadding: GlobalPadding = GlobalPadding(top: 0, bottom: 0, left: 0, right: 0)
// Mouse & Focus Configuration
@Published var mouseFollowsFocus: Bool = false
@Published var focusFollowsMouseMode: FocusMode = .off
@Published var windowOriginDisplay: WindowOriginDisplay = .default
@Published var mouseModifier: MouseModifier = .alt
@Published var mouseAction1: MouseAction = .move
@Published var mouseAction2: MouseAction = .resize
@Published var mouseDropAction: MouseDropAction = .swap
// Window Behavior Configuration
@Published var windowZoomPersist: Bool = false
@Published var windowTopmost: Bool = false
// Animation Configuration
@Published var windowAnimationDuration: Double = 0.0
@Published var windowAnimationEasing: AnimationEasing = .easeOutCubic
// UI State
@Published var isApplying = false
@Published var lastStatus: String?
@Published var showSIPWarning = false
@Published var hasAccessibilityPermission = false
@Published var hasScreenRecordingPermission = false
@Published var scriptingAdditionStatus: String?
@Published var remotePairingPIN: String?
@Published var isRemoteServerRunning: Bool = false
@Published var reachableAddresses: [String] = []
@Published var currentQRCode: NSImage?
var hasUnappliedChanges: Bool {
if fadeInactiveWindows {
return abs(inactiveWindowOpacity - appliedInactiveWindowOpacity) > 0.01
}
return false
}
private var cancellables = Set<AnyCancellable>()
private let configManager = ConfigManager()
private let commandRunner = YabaiCommandRunner()
private let permissionsManager = PermissionsManager.shared
private let remoteAuth = RemoteAuthManager.shared
private var remoteServer: RemoteServer?
init() {
loadCurrentSettings()
checkPermissions()
checkScriptingAdditionStatus()
}
func applyPreset(_ preset: Preset) {
switch preset {
case .default:
fadeInactiveWindows = false
disableShadows = false
menuBarOpacity = 1.0
windowGap = 6.0
focusFollowsMouse = false
case .minimalist:
fadeInactiveWindows = true
disableShadows = true
menuBarOpacity = 0.7
windowGap = 12.0
focusFollowsMouse = true
}
}
// MARK: - Remote Pairing
func startRemoteServerIfNeeded() {
// Attempt to start server if not running
if isRemoteServerRunning { return }
do {
try RemoteServer.shared.start()
remoteServer = RemoteServer.shared
isRemoteServerRunning = true
updateReachableAddresses()
} catch {
isRemoteServerRunning = false
lastStatus = "Failed to start remote server: \(error.localizedDescription)"
}
}
func startPairing() {
// Ensure server is running
startRemoteServerIfNeeded()
// Generate PIN via auth manager
let pin = remoteAuth.generatePIN()
Task { @MainActor in
remotePairingPIN = pin
updateReachableAddresses()
}
}
func stopRemoteServer() {
remoteServer?.stop()
remoteServer = nil
isRemoteServerRunning = false
}
func applyChanges() {
// Check permissions first
if !permissionsManager.hasAccessibilityPermission {
permissionsManager.requestAccessibilityPermission()
return
}
isApplying = true
lastStatus = "Applying changes..."
Task {
do {
// Apply aesthetics
try await applyAestheticSettings()
// Apply focus settings
try await applyFocusSettings()
// Apply advanced configuration
try await applyLayoutSettings()
try await applyPaddingSettings()
try await applyMouseSettings()
try await applyWindowBehaviorSettings()
try await applyAnimationSettings()
// Update config file
try await updateConfigFile()
// Apply menu bar background transparency if enabled
if useSelectiveTransparency {
await SystemTransparencyManager.shared.applySelectiveTransparency(
backgroundAlpha: CGFloat(menuBarBackgroundOpacity),
iconAlpha: 1.0 // Always keep our icon fully visible
)
}
await MainActor.run {
lastStatus = "Changes applied successfully at \(Date.now.formatted(date: .omitted, time: .shortened))"
isApplying = false
}
} catch {
await MainActor.run {
lastStatus = "Error: \(error.localizedDescription)"
isApplying = false
}
}
}
}
private func applyAestheticSettings() async throws {
// Check if scripting addition is needed for window features
let needsScriptingAddition = fadeInactiveWindows || disableShadows
if needsScriptingAddition {
print("SettingsViewModel: Window features enabled, ensuring scripting addition is loaded...")
// Check SIP status first
if !(await scriptingAdditionManager.isSIPDisabled()) {
await MainActor.run {
lastStatus = "❌ SIP must be disabled for window appearance features"
}
throw ScriptingAdditionError.sipEnabled
}
// Ensure scripting addition is loaded
do {
try await scriptingAdditionManager.ensureScriptingAdditionLoaded()
await MainActor.run {
lastStatus = "✅ Ready"
checkScriptingAdditionStatus() // Update status display
}
} catch {
await MainActor.run {
lastStatus = "❌ Failed to load scripting addition: \(error.localizedDescription)"
checkScriptingAdditionStatus() // Update status display
}
throw error
}
}
// Window opacity
if fadeInactiveWindows {
let opacityCommand = "yabai -m config window_opacity on && yabai -m config normal_window_opacity \(inactiveWindowOpacity)"
try await commandRunner.run(command: opacityCommand)
print("SettingsViewModel: Enabled window opacity at \(inactiveWindowOpacity)")
await MainActor.run {
appliedInactiveWindowOpacity = inactiveWindowOpacity
lastStatus = "Set inactive window opacity to \(String(format: "%.1f", inactiveWindowOpacity))"
}
} else {
let opacityCommand = "yabai -m config window_opacity off"
try await commandRunner.run(command: opacityCommand)
print("SettingsViewModel: Disabled window opacity")
await MainActor.run {
appliedInactiveWindowOpacity = 0.0
lastStatus = "Disabled window opacity"
}
}
// Window shadows
if disableShadows {
let shadowCommand = "yabai -m config window_shadow off"
try await commandRunner.run(command: shadowCommand)
print("SettingsViewModel: Disabled window shadows")
} else {
let shadowCommand = "yabai -m config window_shadow on"
try await commandRunner.run(command: shadowCommand)
print("SettingsViewModel: Enabled window shadows")
}
// Menu bar opacity - use selective or standard approach
if useSelectiveTransparency {
// Selective transparency will be applied later in applyChanges()
} else {
try await commandRunner.run(command: "yabai -m config menubar_opacity \(menuBarOpacity)")
}
// Window gap
try await commandRunner.run(command: "yabai -m config window_gap \(Int(windowGap))")
}
private func applyFocusSettings() async throws {
let focusCommand = focusFollowsMouse ?
"yabai -m config focus_follows_mouse autoraise" :
"yabai -m config focus_follows_mouse off"
try await commandRunner.run(command: focusCommand)
}
// MARK: - Layout Configuration
func applyLayoutSettings() async throws {
try await commandRunner.setConfig("layout", value: layoutType.rawValue)
try await commandRunner.setConfig("split_ratio", value: String(format: "%.2f", splitRatio))
try await commandRunner.setConfig("split_type", value: splitType.rawValue)
try await commandRunner.setConfig("auto_balance", value: autoBalance ? "on" : "off")
try await commandRunner.setConfig("window_placement", value: windowPlacement.rawValue)
}
func applyPaddingSettings() async throws {
try await commandRunner.setConfig("top_padding", value: String(Int(globalPadding.top)))
try await commandRunner.setConfig("bottom_padding", value: String(Int(globalPadding.bottom)))
try await commandRunner.setConfig("left_padding", value: String(Int(globalPadding.left)))
try await commandRunner.setConfig("right_padding", value: String(Int(globalPadding.right)))
// windowGap is already handled in applyAestheticSettings
}
func applyMouseSettings() async throws {
try await commandRunner.setConfig("mouse_follows_focus", value: mouseFollowsFocus ? "on" : "off")
try await commandRunner.setConfig("focus_follows_mouse", value: focusFollowsMouseMode.rawValue)
try await commandRunner.setConfig("window_origin_display", value: windowOriginDisplay.rawValue)
try await commandRunner.setConfig("mouse_modifier", value: mouseModifier.rawValue)
try await commandRunner.setConfig("mouse_action1", value: mouseAction1.rawValue)
try await commandRunner.setConfig("mouse_action2", value: mouseAction2.rawValue)
try await commandRunner.setConfig("mouse_drop_action", value: mouseDropAction.rawValue)
}
func applyWindowBehaviorSettings() async throws {
try await commandRunner.setConfig("window_zoom_persist", value: windowZoomPersist ? "on" : "off")
try await commandRunner.setConfig("window_topmost", value: windowTopmost ? "on" : "off")
}
func applyAnimationSettings() async throws {
try await commandRunner.setConfig("window_animation_duration", value: String(format: "%.1f", windowAnimationDuration))
try await commandRunner.setConfig("window_animation_easing", value: windowAnimationEasing.rawValue)
}
private func updateConfigFile() async throws {
var updates: [String: String] = [:]
updates["window_opacity"] = fadeInactiveWindows ? "on" : "off"
updates["normal_window_opacity"] = String(format: "%.2f", fadeInactiveWindows ? inactiveWindowOpacity : 0.0)
updates["window_shadow"] = disableShadows ? "off" : "on"
// Save transparency settings - selective transparency not supported in yabai v7.1.16
updates["menubar_opacity"] = String(format: "%.1f", menuBarOpacity)
updates["window_gap"] = "\(Int(windowGap))"
updates["focus_follows_mouse"] = focusFollowsMouse ? "autoraise" : "off"
// Layout settings
updates["layout"] = layoutType.rawValue
updates["split_ratio"] = String(format: "%.2f", splitRatio)
updates["split_type"] = splitType.rawValue
updates["auto_balance"] = autoBalance ? "on" : "off"
updates["window_placement"] = windowPlacement.rawValue
// Padding settings
updates["top_padding"] = String(Int(globalPadding.top))
updates["bottom_padding"] = String(Int(globalPadding.bottom))
updates["left_padding"] = String(Int(globalPadding.left))
updates["right_padding"] = String(Int(globalPadding.right))
// Mouse settings
updates["mouse_follows_focus"] = mouseFollowsFocus ? "on" : "off"
updates["focus_follows_mouse"] = focusFollowsMouseMode.rawValue
updates["window_origin_display"] = windowOriginDisplay.rawValue
updates["mouse_modifier"] = mouseModifier.rawValue
updates["mouse_action1"] = mouseAction1.rawValue
updates["mouse_action2"] = mouseAction2.rawValue
updates["mouse_drop_action"] = mouseDropAction.rawValue
// Window behavior settings
updates["window_zoom_persist"] = windowZoomPersist ? "on" : "off"
updates["window_topmost"] = windowTopmost ? "on" : "off"
// Animation settings
updates["window_animation_duration"] = String(format: "%.1f", windowAnimationDuration)
updates["window_animation_easing"] = windowAnimationEasing.rawValue
try await configManager.updateSettings(updates)
}
private func loadCurrentSettings() {
Task {
do {
let config = try await configManager.readConfig()
await MainActor.run {
fadeInactiveWindows = config["window_opacity"] == "on"
inactiveWindowOpacity = Double(config["normal_window_opacity"] ?? "0.90") ?? 0.90
appliedInactiveWindowOpacity = inactiveWindowOpacity
disableShadows = config["window_shadow"] == "off"
// Selective transparency not supported in yabai v7.1.16
useSelectiveTransparency = false
menuBarOpacity = Double(config["menubar_opacity"] ?? "1.0") ?? 1.0
menuBarBackgroundOpacity = 1.0
menuBarIconOpacity = 1.0
windowGap = Double(config["window_gap"] ?? "6") ?? 6.0
focusFollowsMouse = config["focus_follows_mouse"] == "autoraise"
// Load layout settings
layoutType = LayoutType(rawValue: config["layout"] ?? "bsp") ?? .bsp
splitRatio = Double(config["split_ratio"] ?? "0.5") ?? 0.5
splitType = SplitType(rawValue: config["split_type"] ?? "auto") ?? .auto
autoBalance = config["auto_balance"] == "on"
windowPlacement = WindowPlacement(rawValue: config["window_placement"] ?? "first_child") ?? .first_child
// Load padding settings
let topPad = Double(config["top_padding"] ?? "0") ?? 0
let bottomPad = Double(config["bottom_padding"] ?? "0") ?? 0
let leftPad = Double(config["left_padding"] ?? "0") ?? 0
let rightPad = Double(config["right_padding"] ?? "0") ?? 0
globalPadding = GlobalPadding(top: topPad, bottom: bottomPad, left: leftPad, right: rightPad)
// Load mouse settings
mouseFollowsFocus = config["mouse_follows_focus"] == "on"
focusFollowsMouseMode = FocusMode(rawValue: config["focus_follows_mouse"] ?? "off") ?? .off
windowOriginDisplay = WindowOriginDisplay(rawValue: config["window_origin_display"] ?? "default") ?? .default
mouseModifier = MouseModifier(rawValue: config["mouse_modifier"] ?? "alt") ?? .alt
mouseAction1 = MouseAction(rawValue: config["mouse_action1"] ?? "move") ?? .move
mouseAction2 = MouseAction(rawValue: config["mouse_action2"] ?? "resize") ?? .resize
mouseDropAction = MouseDropAction(rawValue: config["mouse_drop_action"] ?? "swap") ?? .swap
// Load window behavior settings
windowZoomPersist = config["window_zoom_persist"] == "on"
windowTopmost = config["window_topmost"] == "on"
// Load animation settings
windowAnimationDuration = Double(config["window_animation_duration"] ?? "0.0") ?? 0.0
windowAnimationEasing = AnimationEasing(rawValue: config["window_animation_easing"] ?? "easeOutCubic") ?? .easeOutCubic
}
} catch {
// If we can't read config, use defaults
print("Could not load current settings: \(error)")
}
}
}
private func updateSIPWarning() {
Task {
let sipDisabled = await scriptingAdditionManager.isSIPDisabled()
_ = await scriptingAdditionManager.isScriptingAdditionLoaded()
await MainActor.run {
// Only show warnings if there are actual issues
let needsSIPDisabled = fadeInactiveWindows || disableShadows
_ = fadeInactiveWindows || disableShadows
// Show SIP warning only if SIP is enabled AND user wants features that need it disabled
showSIPWarning = !sipDisabled && needsSIPDisabled
}
}
}
private func checkPermissions() {
hasAccessibilityPermission = permissionsManager.hasAccessibilityPermission
hasScreenRecordingPermission = permissionsManager.hasScreenRecordingPermission
}
private func checkScriptingAdditionStatus() {
Task {
let sipDisabled = await scriptingAdditionManager.isSIPDisabled()
_ = await scriptingAdditionManager.isScriptingAdditionLoaded()
await MainActor.run {
// Only show status when there are window features enabled or issues
let hasWindowFeatures = fadeInactiveWindows || disableShadows
if !sipDisabled && hasWindowFeatures {
scriptingAdditionStatus = "❌ SIP enabled - window features unavailable"
} else if hasWindowFeatures {
scriptingAdditionStatus = "✅ Ready"
} else {
// Don't show status when no window features are enabled
scriptingAdditionStatus = nil
}
}
}
}
func openAccessibilitySettings() {
permissionsManager.openAccessibilitySettings()
}
func updateReachableAddresses() {
Task { @MainActor in
guard let server = RemoteServer.shared as RemoteServer? else { return }
let ips = server.reachableIPAddresses()
let port = server.port
reachableAddresses = ips.map { "http://\($0):\(port)" }
// generate QR for first address if available
if let first = reachableAddresses.first {
currentQRCode = generateQRCode(from: first)
} else {
currentQRCode = nil
}
}
}
private func generateQRCode(from string: String) -> NSImage? {
guard let data = string.data(using: .utf8) else { return nil }
guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
filter.setValue(data, forKey: "inputMessage")
filter.setValue("Q", forKey: "inputCorrectionLevel")
guard let ciImage = filter.outputImage else { return nil }
let transform = CGAffineTransform(scaleX: 10, y: 10)
let scaled = ciImage.transformed(by: transform)
let rep = NSCIImageRep(ciImage: scaled)
let nsImage = NSImage(size: rep.size)
nsImage.addRepresentation(rep)
return nsImage
}
}