import ComposableArchitecture import Dependencies import Foundation import SwiftUI import AppKitExtension public enum ToastLevel { case info case warning case danger case error var icon: String { switch self { case .warning: return "exclamationmark.circle.fill" case .danger: return "exclamationmark.circle.fill" case .error: return "xmark.circle.fill" case .info: return "exclamationmark.triangle.fill" } } var color: Color { switch self { case .warning: return Color(nsColor: .systemOrange) case .danger, .error: return Color(nsColor: .systemRed) case .info: return Color.accentColor } } } public struct ToastKey: EnvironmentKey { public static var defaultValue: (String, ToastLevel) -> Void = { _, _ in } } public extension EnvironmentValues { var toast: (String, ToastLevel) -> Void { get { self[ToastKey.self] } set { self[ToastKey.self] = newValue } } } public struct ToastControllerDependencyKey: DependencyKey { public static let liveValue = ToastController(messages: []) } public extension DependencyValues { var toastController: ToastController { get { self[ToastControllerDependencyKey.self] } set { self[ToastControllerDependencyKey.self] = newValue } } var toast: (String, ToastLevel) -> Void { return { content, level in toastController.toast(content: content, level: level, namespace: nil) } } var namespacedToast: (String, ToastLevel, String) -> Void { return { content, level, namespace in toastController.toast(content: content, level: level, namespace: namespace) } } var persistentToast: (String, String, ToastLevel) -> Void { return { title, content, level in toastController.toast(title: title, content: content, level: level, namespace: nil) } } } public struct ToastButton: Equatable { public let title: String public let action: () -> Void public init(title: String, action: @escaping () -> Void) { self.title = title self.action = action } public static func ==(lhs: ToastButton, rhs: ToastButton) -> Bool { lhs.title == rhs.title } } public class ToastController: ObservableObject { public struct Message: Identifiable, Equatable { public var namespace: String? public var title: String? public var id: UUID public var level: ToastLevel public var content: Text public var button: ToastButton? // Convenience initializer for auto-dismissing messages (no title, no button) public init( id: UUID = UUID(), level: ToastLevel, namespace: String? = nil, content: Text ) { self.id = id self.level = level self.namespace = namespace self.title = nil self.content = content self.button = nil } // Convenience initializer for persistent messages (title is required) public init( id: UUID = UUID(), level: ToastLevel, namespace: String? = nil, title: String, content: Text, button: ToastButton? = nil ) { self.id = id self.level = level self.namespace = namespace self.title = title self.content = content self.button = button } } @Published public var messages: [Message] = [] public init(messages: [Message]) { self.messages = messages } @MainActor private func removeMessageWithAnimation(withId id: UUID) { withAnimation(.easeInOut(duration: 0.2)) { messages.removeAll { $0.id == id } } } private func showMessage(_ message: Message, autoDismissDelay: UInt64?) { Task { @MainActor in withAnimation(.easeInOut(duration: 0.2)) { messages.append(message) messages = messages.suffix(3) } if let autoDismissDelay = autoDismissDelay { try await Task.sleep(nanoseconds: autoDismissDelay) removeMessageWithAnimation(withId: message.id) } } } // Auto-dismissing toast (title and button are not allowed) public func toast( content: String, level: ToastLevel, namespace: String? = nil ) { let message = Message(level: level, namespace: namespace, content: Text(content)) showMessage(message, autoDismissDelay: 4_000_000_000) } // Persistent toast (title is required, button is optional) public func toast( title: String, content: String, level: ToastLevel, namespace: String? = nil, button: ToastButton? = nil ) { // Support markdown in persistent toasts let contentText: Text if let attributedString = try? AttributedString(markdown: content) { contentText = Text(attributedString) } else { contentText = Text(content) } let message = Message( level: level, namespace: namespace, title: title, content: contentText, button: button ) showMessage(message, autoDismissDelay: nil) } public func dismissMessage(withId id: UUID) { Task { @MainActor in removeMessageWithAnimation(withId: id) } } } @Reducer public struct Toast { public typealias Message = ToastController.Message @ObservableState public struct State: Equatable { var isObservingToastController = false public var messages: [Message] = [] public init(messages: [Message] = []) { self.messages = messages } } public enum Action: Equatable { case start case updateMessages([Message]) case toast(String, ToastLevel, String?) case toastPersistent(String, String, ToastLevel, String?, ToastButton?) } @Dependency(\.toastController) var toastController struct CancelID: Hashable {} public init() {} public var body: some ReducerOf { Reduce { state, action in switch action { case .start: guard !state.isObservingToastController else { return .none } state.isObservingToastController = true return .run { send in let stream = AsyncStream<[Message]> { continuation in let cancellable = toastController.$messages.sink { newValue in continuation.yield(newValue) } continuation.onTermination = { _ in cancellable.cancel() } } for await newValue in stream { try Task.checkCancellation() await send(.updateMessages(newValue), animation: .linear(duration: 0.2)) } }.cancellable(id: CancelID(), cancelInFlight: true) case let .updateMessages(messages): state.messages = messages return .none case let .toast(content, level, namespace): toastController.toast(content: content, level: level, namespace: namespace) return .none case let .toastPersistent(title, content, level, namespace, button): toastController .toast( title: title, content: content, level: level, namespace: namespace, button: button ) return .none } } } } public extension NSWorkspace { /// Opens the System Preferences/Settings app at the Extensions pane /// - Parameter extensionPointIdentifier: Optional identifier for specific extension type static func openExtensionsPreferences(extensionPointIdentifier: String? = nil) { if #available(macOS 13.0, *) { var urlString = "x-apple.systempreferences:com.apple.ExtensionsPreferences" if let extensionPointIdentifier = extensionPointIdentifier { urlString += "?extensionPointIdentifier=\(extensionPointIdentifier)" } NSWorkspace.shared.open(URL(string: urlString)!) } else { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") process.arguments = [ "-b", "com.apple.systempreferences", "/System/Library/PreferencePanes/Extensions.prefPane" ] do { try process.run() } catch { // Handle error silently return } } } /// Opens the Xcode Extensions preferences directly static func openXcodeExtensionsPreferences() { openExtensionsPreferences(extensionPointIdentifier: "com.apple.dt.Xcode.extension.source-editor") } static func restartXcode() { // Find current Xcode path before quitting // Restart if we found a valid path if let xcodeURL = getXcodeBundleURL() { // Quit Xcode let script = NSAppleScript(source: "tell application \"Xcode\" to quit") script?.executeAndReturnError(nil) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { NSWorkspace.shared.openApplication( at: xcodeURL, configuration: NSWorkspace.OpenConfiguration() ) } } } }