diff --git a/MiniSim.xcodeproj/project.pbxproj b/MiniSim.xcodeproj/project.pbxproj index 6cc1b2c..90e5c98 100644 --- a/MiniSim.xcodeproj/project.pbxproj +++ b/MiniSim.xcodeproj/project.pbxproj @@ -84,6 +84,13 @@ 76BF0ADF2C8E01B3003BE568 /* CustomCommandService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0ADE2C8E01B3003BE568 /* CustomCommandService.swift */; }; 76BF0AE12C8E027D003BE568 /* DeviceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AE02C8E027D003BE568 /* DeviceConstants.swift */; }; 76BF0AE32C8E041C003BE568 /* CustomCommandServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AE22C8E041C003BE568 /* CustomCommandServiceTests.swift */; }; + 76BF0AE62C8E0F83003BE568 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AE52C8E0F83003BE568 /* Actions.swift */; }; + 76BF0AE82C8E1077003BE568 /* ActionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AE72C8E1077003BE568 /* ActionFactory.swift */; }; + 76BF0AEA2C905A94003BE568 /* AppleUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AE92C905A94003BE568 /* AppleUtils.swift */; }; + 76BF0AEC2C905C37003BE568 /* AndroidDeviceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AEB2C905C37003BE568 /* AndroidDeviceService.swift */; }; + 76BF0AEE2C905C43003BE568 /* IOSDeviceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AED2C905C43003BE568 /* IOSDeviceService.swift */; }; + 76BF0AF02C9061E8003BE568 /* DeviceDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AEF2C9061E8003BE568 /* DeviceDiscoveryService.swift */; }; + 76BF0AF22C907033003BE568 /* DeviceServiceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BF0AF12C907032003BE568 /* DeviceServiceFactory.swift */; }; 76C1396A2C849A3F006CD80C /* MenuIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C139692C849A3F006CD80C /* MenuIcons.swift */; }; 76E4451229D4391000039025 /* Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E4451129D4391000039025 /* Onboarding.swift */; }; 76E4451429D4403F00039025 /* NSNotificationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E4451329D4403F00039025 /* NSNotificationName.swift */; }; @@ -184,6 +191,13 @@ 76BF0ADE2C8E01B3003BE568 /* CustomCommandService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandService.swift; sourceTree = ""; }; 76BF0AE02C8E027D003BE568 /* DeviceConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConstants.swift; sourceTree = ""; }; 76BF0AE22C8E041C003BE568 /* CustomCommandServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandServiceTests.swift; sourceTree = ""; }; + 76BF0AE52C8E0F83003BE568 /* Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = ""; }; + 76BF0AE72C8E1077003BE568 /* ActionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionFactory.swift; sourceTree = ""; }; + 76BF0AE92C905A94003BE568 /* AppleUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleUtils.swift; sourceTree = ""; }; + 76BF0AEB2C905C37003BE568 /* AndroidDeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AndroidDeviceService.swift; sourceTree = ""; }; + 76BF0AED2C905C43003BE568 /* IOSDeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSDeviceService.swift; sourceTree = ""; }; + 76BF0AEF2C9061E8003BE568 /* DeviceDiscoveryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDiscoveryService.swift; sourceTree = ""; }; + 76BF0AF12C907032003BE568 /* DeviceServiceFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceServiceFactory.swift; sourceTree = ""; }; 76C139692C849A3F006CD80C /* MenuIcons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuIcons.swift; sourceTree = ""; }; 76E4451129D4391000039025 /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; 76E4451329D4403F00039025 /* NSNotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSNotificationName.swift; sourceTree = ""; }; @@ -317,11 +331,18 @@ 55CDB0762B1B6D06002418D7 /* Terminal */, 76F269832A2A375900424BDA /* CustomErrors */, 7645D4BD2982A1B100019227 /* DeviceService.swift */, + 76BF0AEB2C905C37003BE568 /* AndroidDeviceService.swift */, + 76BF0AED2C905C43003BE568 /* IOSDeviceService.swift */, + 76BF0AE92C905A94003BE568 /* AppleUtils.swift */, 7699511C2C845B1900462287 /* DeviceParser.swift */, 76F04A10298A5AE000BF9CA3 /* ADB.swift */, 76B70F832B0D5AB4009D87A4 /* Shell.swift */, 76BF0ADE2C8E01B3003BE568 /* CustomCommandService.swift */, 76BF0AE02C8E027D003BE568 /* DeviceConstants.swift */, + 76BF0AE52C8E0F83003BE568 /* Actions.swift */, + 76BF0AE72C8E1077003BE568 /* ActionFactory.swift */, + 76BF0AEF2C9061E8003BE568 /* DeviceDiscoveryService.swift */, + 76BF0AF12C907032003BE568 /* DeviceServiceFactory.swift */, ); path = Service; sourceTree = ""; @@ -634,14 +655,18 @@ 76630F0E29BDE7EB00FB64F9 /* ParametersTable.swift in Sources */, 7677999829C25894009030F8 /* OnboardingPager.swift in Sources */, 52B363EC2AEC10A3006F515C /* ParametersTableForm.swift in Sources */, + 76BF0AEC2C905C37003BE568 /* AndroidDeviceService.swift in Sources */, 7630B2682985C4CF00D8B57D /* About.swift in Sources */, + 76BF0AF02C9061E8003BE568 /* DeviceDiscoveryService.swift in Sources */, 7699511D2C845B1900462287 /* DeviceParser.swift in Sources */, 76630F0C29BDD0C000FB64F9 /* Devices.swift in Sources */, 767C761F29B26ED3009B9AEC /* AccessibilityElement.swift in Sources */, 7610992F2A3F95D90067885A /* NSScriptCommand+utils.swift in Sources */, 4AFACC762AD73D7900EC369F /* NSMenuItem+ConvenienceInit.swift in Sources */, 76F269872A2A39D100424BDA /* Variables.swift in Sources */, + 76BF0AF22C907033003BE568 /* DeviceServiceFactory.swift in Sources */, 764BA3EB2A5AD43F003A78AF /* LaunchDeviceCommand.swift in Sources */, + 76BF0AE62C8E0F83003BE568 /* Actions.swift in Sources */, 7630B2732986C68000D8B57D /* PaneIdentifier.swift in Sources */, 7630B2662985C44A00D8B57D /* Preferences.swift in Sources */, 7625140B2992B46D0060A225 /* Pasteboard+utils.swift in Sources */, @@ -654,6 +679,7 @@ 76AC9AF62A0EA82C00864A8B /* CustomCommands.swift in Sources */, 76489D5C29BFCA330070EF03 /* OnboardingItem.swift in Sources */, 7645D5012982E6FA00019227 /* main.swift in Sources */, + 76BF0AEA2C905A94003BE568 /* AppleUtils.swift in Sources */, 76F2A914299050F9002D4EF6 /* UserDefaults+Configuration.swift in Sources */, 76059BF72AD449DC0008D38B /* OnboardingHeader.swift in Sources */, 763121902A12B45000EE7F48 /* CustomCommandFormViewModel.swift in Sources */, @@ -675,9 +701,11 @@ 7630B26D2986B4FD00D8B57D /* KeyboardShortcuts.swift in Sources */, 76059BF52AD4361C0008D38B /* SetupPreferences.swift in Sources */, 7684FAAF29D202F500230BB0 /* AndroidHomeError.swift in Sources */, + 76BF0AE82C8E1077003BE568 /* ActionFactory.swift in Sources */, 76C1396A2C849A3F006CD80C /* MenuIcons.swift in Sources */, 55CDB0782B1B6D24002418D7 /* TerminalApps.swift in Sources */, 9B225A9C2C7E360D002620BA /* DeviceType.swift in Sources */, + 76BF0AEE2C905C43003BE568 /* IOSDeviceService.swift in Sources */, 7645D4BE2982A1B100019227 /* DeviceService.swift in Sources */, 765ABF382A8BECD900A063CB /* ExecuteCommand.swift in Sources */, ); diff --git a/MiniSim/AppleScript Commands/ExecuteCommand.swift b/MiniSim/AppleScript Commands/ExecuteCommand.swift index 6bc1a5c..70e4db0 100644 --- a/MiniSim/AppleScript Commands/ExecuteCommand.swift +++ b/MiniSim/AppleScript Commands/ExecuteCommand.swift @@ -30,21 +30,13 @@ class ExecuteCommand: NSScriptCommand { guard let menuItem = SubMenuItems.Tags(rawValue: rawTag) else { return nil } + let actionExecutor = ActionExecutor() + actionExecutor.execute( + device: device, + commandTag: menuItem, + itemName: commandName + ) - switch platform { - case .android: - DeviceService.handleAndroidAction( - device: device, - commandTag: menuItem, - itemName: commandName - ) - case .ios: - DeviceService.handleiOSAction( - device: device, - commandTag: menuItem, - itemName: commandName - ) - } return nil } } diff --git a/MiniSim/AppleScript Commands/GetDevicesCommand.swift b/MiniSim/AppleScript Commands/GetDevicesCommand.swift index b1f0c1a..3234be8 100644 --- a/MiniSim/AppleScript Commands/GetDevicesCommand.swift +++ b/MiniSim/AppleScript Commands/GetDevicesCommand.swift @@ -21,16 +21,7 @@ class GetDevicesCommand: NSScriptCommand { } do { - switch (platform, deviceType) { - case (.android, .physical): - return try self.encode(DeviceService.getAndroidPhysicalDevices()) - case (.android, .virtual): - return try self.encode(DeviceService.getAndroidEmulators()) - case (.ios, .physical): - return try self.encode(DeviceService.getIOSPhysicalDevices()) - case (.ios, .virtual): - return try self.encode(DeviceService.getIOSSimulators()) - } + return try self.encode(DeviceServiceFactory.getDeviceDiscoveryService(platform: platform).getDevices(type: deviceType)) } catch { scriptErrorNumber = NSInternalScriptError return nil diff --git a/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift b/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift index 2a1d238..365901c 100644 --- a/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift +++ b/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift @@ -17,8 +17,8 @@ class LaunchDeviceCommand: NSScriptCommand { do { var devices: [Device] = [] - try devices.append(contentsOf: DeviceService.getIOSDevices()) - try devices.append(contentsOf: DeviceService.getAndroidDevices()) + try devices.append(contentsOf: DeviceServiceFactory.getDeviceDiscoveryService(platform: .ios).getDevices()) + try devices.append(contentsOf: DeviceServiceFactory.getDeviceDiscoveryService(platform: .android).getDevices()) guard let device = devices.first(where: { $0.name == deviceName }) else { scriptErrorNumber = NSInternalScriptError @@ -26,11 +26,11 @@ class LaunchDeviceCommand: NSScriptCommand { } if device.booted { - DeviceService.focusDevice(device) + device.focus() return nil } - DeviceService.launch(device: device) { _ in } + try? device.launch() return nil } catch { scriptErrorNumber = NSInternalScriptError diff --git a/MiniSim/Menu.swift b/MiniSim/Menu.swift index 2c6702d..aed77c9 100644 --- a/MiniSim/Menu.swift +++ b/MiniSim/Menu.swift @@ -11,6 +11,7 @@ import UserNotifications class Menu: NSMenu { public let maxKeyEquivalent = 9 + let actionExecutor = ActionExecutor() var devices: [Device] = [] { didSet { @@ -71,7 +72,7 @@ class Menu: NSMenu { func updateDevicesList() { let userDefaults = UserDefaults.standard - DeviceService.getAllDevices( + DeviceServiceFactory.getAllDevices( android: userDefaults.enableAndroidEmulators && userDefaults.androidHome != nil, iOS: userDefaults.enableiOSSimulators ) { devices, error in @@ -93,30 +94,28 @@ class Menu: NSMenu { .forEach(safeRemoveItem) } - @objc private func androidSubMenuClick(_ sender: NSMenuItem) { - guard let tag = SubMenuItems.Tags(rawValue: sender.tag) else { return } - guard let device = getDeviceByName(name: sender.parent?.title ?? "") else { return } + @objc private func subMenuClick(_ sender: NSMenuItem) { + guard let tag = SubMenuItems.Tags(rawValue: sender.tag) else { return } + guard let device = getDeviceByName(name: sender.parent?.title ?? "") else { return } - DeviceService.handleAndroidAction(device: device, commandTag: tag, itemName: sender.title) - } - - @objc private func IOSSubMenuClick(_ sender: NSMenuItem) { - guard let tag = SubMenuItems.Tags(rawValue: sender.tag) else { return } - guard let device = getDeviceByName(name: sender.parent?.title ?? "") else { return } - - DeviceService.handleiOSAction(device: device, commandTag: tag, itemName: sender.title) + actionExecutor.execute( + device: device, + commandTag: tag, + itemName: sender.title + ) } @objc private func deviceItemClick(_ sender: NSMenuItem) { guard let device = getDeviceByName(name: sender.title), device.type == .virtual else { return } - if device.booted { - DeviceService.focusDevice(device) - return - } - - DeviceService.launch(device: device) { error in - if let error { + DispatchQueue.global().async { + if device.booted { + device.focus() + return + } + do { + try device.launch() + } catch { NSAlert.showError(message: error.localizedDescription) } } @@ -242,7 +241,7 @@ class Menu: NSMenu { let subMenu = NSMenu() let platform = device.platform let deviceType = device.type - let callback = platform == .android ? #selector(androidSubMenuClick) : #selector(IOSSubMenuClick) + let callback = #selector(subMenuClick) let actionsSubMenu = createActionsSubMenu( for: SubMenuItems.items(platform: platform, deviceType: deviceType), isDeviceBooted: device.booted, diff --git a/MiniSim/MiniSim.swift b/MiniSim/MiniSim.swift index 9c34b03..961a420 100644 --- a/MiniSim/MiniSim.swift +++ b/MiniSim/MiniSim.swift @@ -171,7 +171,7 @@ class MiniSim: NSObject { return } - DeviceService.clearDerivedData { amountCleared, error in + AppleUtils.clearDerivedData { amountCleared, error in guard error == nil else { NSAlert.showError(message: error?.localizedDescription ?? "Failed to clear derived data.") return @@ -186,6 +186,11 @@ class MiniSim: NSObject { } } + static func showSuccessMessage(title: String, message: String) { + UNUserNotificationCenter.showNotification(title: title, body: message) + NotificationCenter.default.post(name: .commandDidSucceed, object: nil) + } + private var mainMenu: [NSMenuItem] { MainMenuActions.allCases.map { item in NSMenuItem( diff --git a/MiniSim/Model/Device.swift b/MiniSim/Model/Device.swift index d34d783..df92611 100644 --- a/MiniSim/Model/Device.swift +++ b/MiniSim/Model/Device.swift @@ -1,34 +1,29 @@ -// -// Device.swift -// MiniSim -// -// Created by Oskar Kwaśniewski on 25/01/2023. -// +import Foundation struct Device: Hashable, Codable { - var name: String - var version: String? - var identifier: String? - var booted: Bool - var platform: Platform - var type: DeviceType + var name: String + var version: String? + var identifier: String? + var booted: Bool + var platform: Platform + var type: DeviceType - var displayName: String { - switch platform { - case .ios: - if let version { - return "\(name) - (\(version))" - } - return name + var displayName: String { + switch platform { + case .ios: + if let version { + return "\(name) - (\(version))" + } + return name - case .android: - return name - } + case .android: + return name } + } - enum CodingKeys: String, CodingKey { - case name, version, identifier, booted, platform, displayName, type - } + enum CodingKeys: String, CodingKey { + case name, version, identifier, booted, platform, displayName, type + } init( name: String, @@ -38,32 +33,32 @@ struct Device: Hashable, Codable { platform: Platform, type: DeviceType ) { - self.name = name - self.version = version - self.identifier = identifier - self.booted = booted - self.platform = platform - self.type = type - } + self.name = name + self.version = version + self.identifier = identifier + self.booted = booted + self.platform = platform + self.type = type + } - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - name = try values.decode(String.self, forKey: .name) - version = try values.decode(String.self, forKey: .version) - identifier = try values.decode(String.self, forKey: .identifier) - booted = try values.decode(Bool.self, forKey: .booted) - platform = try values.decode(Platform.self, forKey: .platform) - type = try values.decode(DeviceType.self, forKey: .type) - } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + name = try values.decode(String.self, forKey: .name) + version = try values.decode(String.self, forKey: .version) + identifier = try values.decode(String.self, forKey: .identifier) + booted = try values.decode(Bool.self, forKey: .booted) + platform = try values.decode(Platform.self, forKey: .platform) + type = try values.decode(DeviceType.self, forKey: .type) + } - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(version, forKey: .version) - try container.encode(identifier, forKey: .identifier) - try container.encode(booted, forKey: .booted) - try container.encode(platform, forKey: .platform) - try container.encode(displayName, forKey: .displayName) - try container.encode(type, forKey: .type) - } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(version, forKey: .version) + try container.encode(identifier, forKey: .identifier) + try container.encode(booted, forKey: .booted) + try container.encode(platform, forKey: .platform) + try container.encode(displayName, forKey: .displayName) + try container.encode(type, forKey: .type) + } } diff --git a/MiniSim/Service/ActionFactory.swift b/MiniSim/Service/ActionFactory.swift new file mode 100644 index 0000000..d250e62 --- /dev/null +++ b/MiniSim/Service/ActionFactory.swift @@ -0,0 +1,93 @@ +import AppKit +import Foundation + +protocol ActionFactory { + static func createAction(for tag: SubMenuItems.Tags, device: Device, itemName: String) -> Action +} + +class AndroidActionFactory: ActionFactory { + static func createAction(for tag: SubMenuItems.Tags, device: Device, itemName: String) -> any Action { + switch tag { + case .copyName: + return CopyNameAction(device: device) + case .copyID: + return CopyIDAction(device: device) + case .coldBoot: + return ColdBootCommand(device: device) + case .noAudio: + return NoAudioCommand(device: device) + case .toggleA11y: + return ToggleA11yCommand(device: device) + case .paste: + return PasteClipboardAction(device: device) + case .delete: + return DeleteAction(device: device) + case .customCommand: + return CustomCommandAction(device: device, itemName: itemName) + case .logcat: + return LaunchLogCat(device: device) + } + } +} + +class IOSActionFactory: ActionFactory { + static func createAction(for tag: SubMenuItems.Tags, device: Device, itemName: String) -> any Action { + switch tag { + case .copyName: + return CopyNameAction(device: device) + case .copyID: + return CopyIDAction(device: device) + case .customCommand: + return CustomCommandAction(device: device, itemName: itemName) + case .coldBoot: + return ColdBootCommand(device: device) + case .delete: + return DeleteAction(device: device) + default: + fatalError("Unhandled action tag: \(tag)") + } + } +} + +class ActionExecutor { + private let queue: DispatchQueue + + init(queue: DispatchQueue = DispatchQueue(label: "com.MiniSim.ActionExecutor")) { + self.queue = queue + } + + func execute( + device: Device, + commandTag: SubMenuItems.Tags, + itemName: String + ) { + let action: Action + + switch device.platform { + case .android: + action = AndroidActionFactory.createAction( + for: commandTag, + device: device, + itemName: itemName + ) + case .ios: + action = IOSActionFactory.createAction( + for: commandTag, + device: device, + itemName: itemName + ) + } + + if action.showQuestionDialog() { + return + } + + queue.async { + do { + try action.execute() + } catch { + NSAlert.showError(message: error.localizedDescription) + } + } + } +} diff --git a/MiniSim/Service/Actions.swift b/MiniSim/Service/Actions.swift new file mode 100644 index 0000000..3357b3a --- /dev/null +++ b/MiniSim/Service/Actions.swift @@ -0,0 +1,149 @@ +import AppKit +import Foundation + +protocol Action { + func execute() throws + func showQuestionDialog() -> Bool +} + +extension Action { + func showQuestionDialog() -> Bool { + false + } +} + +// MARK: General Actions + +class CopyIDAction: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + if let deviceId = device.identifier { + NSPasteboard.general.copyToPasteboard(text: deviceId) + MiniSim.showSuccessMessage(title: "Device ID copied to clipboard!", message: deviceId) + } + } +} + +class CopyNameAction: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + NSPasteboard.general.copyToPasteboard(text: device.name) + MiniSim.showSuccessMessage(title: "Device name copied to clipboard!", message: device.name) + } +} + +class DeleteAction: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func showQuestionDialog() -> Bool { + !NSAlert.showQuestionDialog( + title: "Are you sure?", + message: "Are you sure you want to delete this device?" + ) + } + + func execute() throws { + try self.device.delete() + MiniSim.showSuccessMessage(title: "Device deleted!", message: self.device.name) + NotificationCenter.default.post(name: .deviceDeleted, object: nil) + } +} + +class CustomCommandAction: Action { + let device: Device + let itemName: String + + init(device: Device, itemName: String) { + self.device = device + self.itemName = itemName + } + + func execute() throws { + if let command = CustomCommandService.getCustomCommand(platform: .android, commandName: itemName) { + try CustomCommandService.runCustomCommand(device, command: command) + } + } +} + +// MARK: Android Actions + +class PasteClipboardAction: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + guard let clipboard = NSPasteboard.general.pasteboardItems?.first, + let text = clipboard.string(forType: .string) else { + return + } + try ADB.sendText(device: device, text: text) + } +} + +class LaunchLogCat: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + try ADB.launchLogCat(device: device) + } +} + +class ColdBootCommand: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + try device.launch(additionalArgs: ["-no-snapshot"]) + } +} + +class NoAudioCommand: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + try device.launch(additionalArgs: ["-no-audio"]) + } +} + +class ToggleA11yCommand: Action { + let device: Device + + init(device: Device) { + self.device = device + } + + func execute() throws { + guard let deviceId = device.identifier else { + return + } + ADB.toggleAccesibility(deviceId: deviceId) + } +} diff --git a/MiniSim/Service/Adb.swift b/MiniSim/Service/Adb.swift index 5ba89f6..6ce9fd7 100644 --- a/MiniSim/Service/Adb.swift +++ b/MiniSim/Service/Adb.swift @@ -8,131 +8,154 @@ import Foundation protocol ADBProtocol { - static var shell: ShellProtocol { get set } - - static func getAdbPath() throws -> String - static func getEmulatorPath() throws -> String - static func getAndroidHome() throws -> String - static func getAdbId(for deviceName: String) throws -> String - static func checkAndroidHome( - path: String, - fileManager: FileManager - ) throws -> Bool - static func isAccesibilityOn(deviceId: String) -> Bool - static func toggleAccesibility(deviceId: String) + static var shell: ShellProtocol { get set } + + static func getAdbPath() throws -> String + static func getEmulatorPath() throws -> String + static func getAndroidHome() throws -> String + static func getAdbId(for deviceName: String) throws -> String + static func checkAndroidHome( + path: String, + fileManager: FileManager + ) throws -> Bool + static func isAccesibilityOn(deviceId: String) -> Bool + static func toggleAccesibility(deviceId: String) + static func sendText(device: Device, text: String) throws + static func launchLogCat(device: Device) throws } final class ADB: ADBProtocol { - static var shell: ShellProtocol = Shell() - - static let talkbackOn = "com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService" - static let talkbackOff = "com.android.talkback/com.google.android.marvin.talkback.TalkBackService" - - private enum Paths: String { - case home = "/Android/sdk" - case emulator = "/emulator/emulator" - case adb = "/platform-tools/adb" - case avd = "/cmdline-tools/latest/bin/avdmanager" + static var shell: ShellProtocol = Shell() + + static let talkbackOn = "com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService" + static let talkbackOff = "com.android.talkback/com.google.android.marvin.talkback.TalkBackService" + + private enum Paths: String { + case home = "/Android/sdk" + case emulator = "/emulator/emulator" + case adb = "/platform-tools/adb" + case avd = "/cmdline-tools/latest/bin/avdmanager" + } + + /** + Gets `ANDROID_HOME` path. First checks in UserDefaults if androidHome exists + if not defaults to: `/Users//Library/Android/sdk`. + */ + static func getAndroidHome() throws -> String { + if let savedAndroidHome = UserDefaults.standard.androidHome, !savedAndroidHome.isEmpty { + return savedAndroidHome } - /** - Gets `ANDROID_HOME` path. First checks in UserDefaults if androidHome exists - if not defaults to: `/Users//Library/Android/sdk`. - */ - static func getAndroidHome() throws -> String { - if let savedAndroidHome = UserDefaults.standard.androidHome, !savedAndroidHome.isEmpty { - return savedAndroidHome - } - - let libraryDirectory = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true) - guard let path = libraryDirectory.first else { - throw DeviceError.androidStudioError - } - - return path + Paths.home.rawValue + let libraryDirectory = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true) + guard let path = libraryDirectory.first else { + throw DeviceError.androidStudioError } - static func getAdbPath() throws -> String { - try getAndroidHome() + Paths.adb.rawValue + return path + Paths.home.rawValue + } + + static func getAdbPath() throws -> String { + try getAndroidHome() + Paths.adb.rawValue + } + + static func getAvdPath() throws -> String { + try getAndroidHome() + Paths.avd.rawValue + } + + /** + Checks if passed path exists and points to `ANDROID_HOME`. + */ + @discardableResult static func checkAndroidHome( + path: String, + fileManager: FileManager = .default + ) throws -> Bool { + if !fileManager.fileExists(atPath: path) { + throw AndroidHomeError.pathNotFound } - static func getAvdPath() throws -> String { - try getAndroidHome() + Paths.avd.rawValue + do { + try shell.execute(command: "\(path)" + Paths.emulator.rawValue, arguments: ["-list-avds"]) + } catch { + throw AndroidHomeError.pathNotCorrect } - - /** - Checks if passed path exists and points to `ANDROID_HOME`. - */ - @discardableResult static func checkAndroidHome( - path: String, - fileManager: FileManager = .default - ) throws -> Bool { - if !fileManager.fileExists(atPath: path) { - throw AndroidHomeError.pathNotFound + return true + } + + static func getEmulatorPath() throws -> String { + try getAndroidHome() + Paths.emulator.rawValue + } + + static func getAdbId(for deviceName: String) throws -> String { + let adbPath = try Self.getAdbPath() + let onlineDevices = try shell.execute(command: "\(adbPath) devices") + let splitted = onlineDevices.components(separatedBy: "\n") + + for line in splitted { + let device = line.match("^emulator-[0-9]+") + guard let deviceId = device.first?.first else { continue } + + let output = try? shell.execute( + command: "\(adbPath) -s \(deviceId) emu avd name" + ) + .components(separatedBy: "\n") + + if let name = output?.first { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDeviceName = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedName == trimmedDeviceName { + return deviceId } - - do { - try shell.execute(command: "\(path)" + Paths.emulator.rawValue, arguments: ["-list-avds"]) - } catch { - throw AndroidHomeError.pathNotCorrect - } - return true + } } + throw DeviceError.deviceNotFound + } - static func getEmulatorPath() throws -> String { - try getAndroidHome() + Paths.emulator.rawValue + static func isAccesibilityOn(deviceId: String) -> Bool { + guard let adbPath = try? Self.getAdbPath() else { + return false } - - static func getAdbId(for deviceName: String) throws -> String { - let adbPath = try Self.getAdbPath() - let onlineDevices = try shell.execute(command: "\(adbPath) devices") - let splitted = onlineDevices.components(separatedBy: "\n") - - for line in splitted { - let device = line.match("^emulator-[0-9]+") - guard let deviceId = device.first?.first else { continue } - - let output = try? shell.execute( - command: "\(adbPath) -s \(deviceId) emu avd name" - ) - .components(separatedBy: "\n") - - if let name = output?.first { - let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedDeviceName = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedName == trimmedDeviceName { - return deviceId - } - } - } - throw DeviceError.deviceNotFound + let shellCommand = "\(adbPath) -s \(deviceId) shell settings get secure enabled_accessibility_services" + guard let result = try? shell.execute(command: shellCommand) else { + return false } - static func isAccesibilityOn(deviceId: String) -> Bool { - guard let adbPath = try? Self.getAdbPath() else { - return false - } - let shellCommand = "\(adbPath) -s \(deviceId) shell settings get secure enabled_accessibility_services" - guard let result = try? shell.execute(command: shellCommand) else { - return false - } + if result == talkbackOn { + return true + } - if result == talkbackOn { - return true - } + return false + } - return false + static func toggleAccesibility(deviceId: String) { + guard let adbPath = try? Self.getAdbPath() else { + return + } + let a11yIsEnabled = Self.isAccesibilityOn(deviceId: deviceId) + let value = a11yIsEnabled ? ADB.talkbackOff : ADB.talkbackOn + let shellCmd = "\(adbPath) -s \(deviceId) shell settings put secure enabled_accessibility_services \(value)" + + // Ignore the error if toggling a11y fails. + _ = try? shell.execute(command: shellCmd) + } + + static func sendText(device: Device, text: String) throws { + let adbPath = try ADB.getAdbPath() + guard let deviceId = device.identifier else { + throw DeviceError.deviceNotFound } - static func toggleAccesibility(deviceId: String) { - guard let adbPath = try? Self.getAdbPath() else { - return - } - let a11yIsEnabled = Self.isAccesibilityOn(deviceId: deviceId) - let value = a11yIsEnabled ? ADB.talkbackOff : ADB.talkbackOn - let shellCmd = "\(adbPath) -s \(deviceId) shell settings put secure enabled_accessibility_services \(value)" + let formattedText = text.replacingOccurrences(of: " ", with: "%s").replacingOccurrences(of: "'", with: "''") - // Ignore the error if toggling a11y fails. - _ = try? shell.execute(command: shellCmd) + try shell.execute(command: "\(adbPath) -s \(deviceId) shell input text \"\(formattedText)\"") + } + + static func launchLogCat(device: Device) throws { + guard let deviceId = device.identifier else { + throw DeviceError.deviceNotFound } + + guard let adbPath = try? ADB.getAdbPath() else { return } + let logcatCommand = "\(adbPath) -s \(deviceId) logcat -v color" + try TerminalService.launchTerminal(command: logcatCommand) + } } diff --git a/MiniSim/Service/AndroidDeviceService.swift b/MiniSim/Service/AndroidDeviceService.swift new file mode 100644 index 0000000..b866c36 --- /dev/null +++ b/MiniSim/Service/AndroidDeviceService.swift @@ -0,0 +1,54 @@ +import AppKit +import Foundation + +class AndroidDeviceService: DeviceServiceCommon { + var shell: ShellProtocol = Shell() + var device: Device + + init(device: Device) { + self.device = device + } + + func deleteDevice() throws { + Thread.assertBackgroundThread() + let avdPath = try ADB.getAvdPath() + let adbPath = try ADB.getAdbPath() + if device.booted { + guard let deviceId = device.identifier else { + throw DeviceError.deviceNotFound + } + try shell.execute(command: "\(adbPath) -s \(deviceId) emu kill") + } + try shell.execute(command: "\(avdPath) delete avd -n \"\(device.name)\"") + } + + func launchDevice(additionalArgs: [String] = []) throws { + Thread.assertBackgroundThread() + let emulatorPath = try ADB.getEmulatorPath() + var arguments = ["@\(device.name)"] + let formattedArguments = additionalArgs + .filter { !$0.isEmpty } + .map { $0.hasPrefix("-") ? $0 : "-\($0)" } + arguments.append(contentsOf: getAndroidLaunchParams()) + arguments.append(contentsOf: formattedArguments) + do { + try shell.execute(command: emulatorPath, arguments: arguments) + } catch { + // Ignore force qutting emulator (CMD + Q) + if error.localizedDescription.contains("unexpected system image feature string") { + return + } + throw error + } + } + + func getAndroidLaunchParams() -> [String] { + guard let paramData = UserDefaults.standard.parameters else { return [] } + guard let parameters = try? JSONDecoder().decode([Parameter].self, from: paramData) else { + return [] + } + + return parameters.filter { $0.enabled } + .map { $0.command } + } +} diff --git a/MiniSim/Service/AppleUtils.swift b/MiniSim/Service/AppleUtils.swift new file mode 100644 index 0000000..f1280c6 --- /dev/null +++ b/MiniSim/Service/AppleUtils.swift @@ -0,0 +1,44 @@ +import AppKit + +class AppleUtils { + static var shell: ShellProtocol = Shell() + + static func clearDerivedData( + completionQueue: DispatchQueue = .main, + completion: @escaping (String, Error?) -> Void + ) { + DispatchQueue.global(qos: .background).async { + do { + let amountCleared = try? shell.execute(command: "du -sh \(DeviceConstants.derivedDataLocation)") + .match(###"\d+\.?\d+\w+"###).first?.first + try shell.execute(command: "rm -rf \(DeviceConstants.derivedDataLocation)") + completionQueue.async { + completion(amountCleared ?? "", nil) + } + } catch { + completionQueue.async { + completion("", error) + } + } + } + } + + static func launchSimulatorApp(uuid: String) throws { + let isSimulatorRunning = NSWorkspace.shared.runningApplications + .contains { $0.bundleIdentifier == "com.apple.iphonesimulator" } + + if !isSimulatorRunning { + guard let activeDeveloperDir = try? shell.execute( + command: DeviceConstants.ProcessPaths.xcodeSelect.rawValue, + arguments: ["-p"] + ) + .trimmingCharacters(in: .whitespacesAndNewlines) else { + throw DeviceError.xcodeError + } + try shell.execute( + command: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator", + arguments: ["--args", "-CurrentDeviceUDID", uuid] + ) + } + } +} diff --git a/MiniSim/Service/CustomCommandService.swift b/MiniSim/Service/CustomCommandService.swift index 2f38862..20db351 100644 --- a/MiniSim/Service/CustomCommandService.swift +++ b/MiniSim/Service/CustomCommandService.swift @@ -42,7 +42,7 @@ class CustomCommandService { do { try shell.execute(command: commandToExecute) if command.bootsDevice ?? false && command.platform == .ios { - try? DeviceService.launchSimulatorApp(uuid: deviceID) + try? AppleUtils.launchSimulatorApp(uuid: deviceID) } NotificationCenter.default.post(name: .commandDidSucceed, object: nil) } catch { diff --git a/MiniSim/Service/DeviceDiscoveryService.swift b/MiniSim/Service/DeviceDiscoveryService.swift new file mode 100644 index 0000000..1a443b5 --- /dev/null +++ b/MiniSim/Service/DeviceDiscoveryService.swift @@ -0,0 +1,96 @@ +import Foundation + +protocol DeviceDiscoveryService { + var shell: ShellProtocol { get set } + + func getDevices(type: DeviceType?) throws -> [Device] + func getDevices() throws -> [Device] + func checkSetup() throws -> Bool +} + +extension DeviceDiscoveryService { + func getDevices() throws -> [Device] { + try getDevices(type: nil) + } +} + +class AndroidDeviceDiscovery: DeviceDiscoveryService { + var shell: ShellProtocol = Shell() + + func getDevices(type: DeviceType? = nil) throws -> [Device] { + switch type { + case .physical: + return try getAndroidPhysicalDevices() + case .virtual: + return try getAndroidEmulators() + case nil: + let emulators = try getAndroidEmulators() + let devices = try getAndroidPhysicalDevices() + return emulators + devices + } + } + + private func getAndroidPhysicalDevices() throws -> [Device] { + let adbPath = try ADB.getAdbPath() + let output = try shell.execute(command: adbPath, arguments: ["devices", "-l"]) + + return DeviceParserFactory().getParser(.androidPhysical).parse(output) + } + + private func getAndroidEmulators() throws -> [Device] { + let emulatorPath = try ADB.getEmulatorPath() + let output = try shell.execute(command: emulatorPath, arguments: ["-list-avds"]) + + return DeviceParserFactory().getParser(.androidEmulator).parse(output) + } + + func checkSetup() throws -> Bool { + let emulatorPath = try ADB.getAndroidHome() + try ADB.checkAndroidHome(path: emulatorPath) + return true + } +} + +class IOSDeviceDiscovery: DeviceDiscoveryService { + var shell: ShellProtocol = Shell() + + func getDevices(type: DeviceType? = nil) throws -> [Device] { + switch type { + case .physical: + return try getIOSPhysicalDevices() + case .virtual: + return try getIOSSimulators() + case nil: + let simulators = try getIOSSimulators() + let devices = try getIOSPhysicalDevices() + return simulators + devices + } + } + + func getIOSPhysicalDevices() throws -> [Device] { + let tempDirectory = FileManager.default.temporaryDirectory + let outputFile = tempDirectory.appendingPathComponent("iosPhysicalDevices.json") + + guard (try? shell.execute( + command: DeviceConstants.ProcessPaths.xcrun.rawValue, + arguments: ["devicectl", "list", "devices", "-j \(outputFile.path)"] + )) != nil else { + return [] + } + + let jsonString = try String(contentsOf: outputFile) + return DeviceParserFactory().getParser(.iosPhysical).parse(jsonString) + } + + func getIOSSimulators() throws -> [Device] { + let output = try shell.execute( + command: DeviceConstants.ProcessPaths.xcrun.rawValue, + arguments: ["simctl", "list", "devices", "available"] + ) + return DeviceParserFactory().getParser(.iosSimulator).parse(output) + } + + func checkSetup() throws -> Bool { + FileManager.default.fileExists(atPath: DeviceConstants.ProcessPaths.xcrun.rawValue) + } +} diff --git a/MiniSim/Service/DeviceService.swift b/MiniSim/Service/DeviceService.swift index 6b9e78f..af022f5 100644 --- a/MiniSim/Service/DeviceService.swift +++ b/MiniSim/Service/DeviceService.swift @@ -1,453 +1,81 @@ -// -// DeviceService.swift -// MiniSim -// -// Created by Oskar Kwaśniewski on 26/01/2023. -// - import AppKit import Foundation -import ShellOut import UserNotifications -protocol DeviceServiceProtocol { - static func getIOSDevices() throws -> [Device] - static func checkXcodeSetup() -> Bool - static func deleteSimulator(uuid: String) throws - - static func toggleA11y(device: Device) throws - static func getAndroidDevices() throws -> [Device] - static func sendText(device: Device, text: String) throws - static func checkAndroidSetup() throws -> String +protocol DeviceServiceCommon { + var shell: ShellProtocol { get set } + var device: Device { get } - static func focusDevice(_ device: Device) - static func showSuccessMessage(title: String, message: String) + func deleteDevice() throws + func launchDevice(additionalArgs: [String]) throws + func focusDevice() } -class DeviceService: DeviceServiceProtocol { - private static let queue = DispatchQueue( - label: "com.MiniSim.DeviceService", - qos: .userInteractive, - attributes: .concurrent - ) - - static func focusDevice(_ device: Device) { - queue.async { - let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular } - - if let uuid = device.identifier, device.platform == .ios { - try? Self.launchSimulatorApp(uuid: uuid) - } - - for app in runningApps { - guard - let bundleURL = app.bundleURL?.absoluteString, - bundleURL.contains(DeviceConstants.BundleURL.simulator.rawValue) || - bundleURL.contains(DeviceConstants.BundleURL.emulator.rawValue) else { - continue - } - let isAndroid = bundleURL.contains(DeviceConstants.BundleURL.emulator.rawValue) - - for window in AccessibilityElement.allWindowsForPID(app.processIdentifier) { - guard let windowTitle = window.attribute(key: .title, type: String.self), - !windowTitle.isEmpty else { - continue - } - - if !Self.matchDeviceTitle(windowTitle: windowTitle, device: device) { - continue - } - - if isAndroid { - AccessibilityElement.forceFocus(pid: app.processIdentifier) - } else { - window.performAction(key: kAXRaiseAction) - app.activate(options: [.activateIgnoringOtherApps]) - } - } - } - } +extension Device { + var deviceService: DeviceServiceCommon { + DeviceServiceFactory.getDeviceService(device: self) } - private static func matchDeviceTitle(windowTitle: String, device: Device) -> Bool { - if device.platform == .android { - let deviceName = windowTitle.match(#"(?<=- ).*?(?=:)"#).first?.first - return deviceName == device.name - } - - let deviceName = windowTitle.match(#"^[^–]*"#).first?.first?.trimmingCharacters(in: .whitespacesAndNewlines) - - return deviceName == device.name - } - - static func checkXcodeSetup() -> Bool { - FileManager.default.fileExists(atPath: DeviceConstants.ProcessPaths.xcrun.rawValue) - } - - static func checkAndroidSetup() throws -> String { - let emulatorPath = try ADB.getAndroidHome() - try ADB.checkAndroidHome(path: emulatorPath) - return emulatorPath - } - - static func showSuccessMessage(title: String, message: String) { - UNUserNotificationCenter.showNotification(title: title, body: message) - NotificationCenter.default.post(name: .commandDidSucceed, object: nil) - } - - static func getAllDevices( - android: Bool, - iOS: Bool, - completionQueue: DispatchQueue = .main, - completion: @escaping ([Device], Error?) -> Void - ) { - queue.async { - do { - var devicesArray: [Device] = [] - - if android { - try devicesArray.append(contentsOf: getAndroidDevices()) - } - - if iOS { - try devicesArray.append(contentsOf: getIOSDevices()) - } - - completionQueue.async { - completion(devicesArray, nil) - } - } catch { - completionQueue.async { - completion([], error) - } - } - } + func delete() throws { + try deviceService.deleteDevice() } - private static func launch(device: Device) throws { - Thread.assertBackgroundThread() - switch device.platform { - case .ios: - try launchDevice(uuid: device.identifier ?? "") - case .android: - try launchDevice(name: device.name) - } + func focus() { + deviceService.focusDevice() } - static func launch(device: Device, completionQueue: DispatchQueue = .main, completion: @escaping (Error?) -> Void) { - self.queue.async { - do { - try self.launch(device: device) - completionQueue.async { - completion(nil) - } - } catch { - if error.localizedDescription.contains(DeviceConstants.deviceBootedError) { - return - } - completionQueue.async { - completion(error) - } - } - } + func launch(additionalArgs: [String] = []) throws { + try deviceService.launchDevice(additionalArgs: additionalArgs) } } -// MARK: iOS Methods -extension DeviceService { - static func clearDerivedData( - completionQueue: DispatchQueue = .main, - completion: @escaping (String, Error?) -> Void - ) { - self.queue.async { - do { - let amountCleared = try? shellOut(to: "du -sh \(DeviceConstants.derivedDataLocation)") - .match(###"\d+\.?\d+\w+"###).first?.first - try shellOut(to: "rm -rf \(DeviceConstants.derivedDataLocation)") - completionQueue.async { - completion(amountCleared ?? "", nil) - } - } catch { - completionQueue.async { - completion("", error) - } - } - } - } - - static func getIOSDevices() throws -> [Device] { +extension DeviceServiceCommon { + func focusDevice() { Thread.assertBackgroundThread() - let simulators = try getIOSSimulators() - let devices = try getIOSPhysicalDevices() - return simulators + devices - } - static func getIOSPhysicalDevices() throws -> [Device] { - let tempDirectory = FileManager.default.temporaryDirectory - let outputFile = tempDirectory.appendingPathComponent("iosPhysicalDevices.json") + let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular } - guard (try? shellOut( - to: DeviceConstants.ProcessPaths.xcrun.rawValue, - arguments: ["devicectl", "list", "devices", "-j \(outputFile.path)"] - )) != nil else { - return [] + if let uuid = device.identifier, device.platform == .ios { + try? AppleUtils.launchSimulatorApp(uuid: uuid) } - let jsonString = try String(contentsOf: outputFile) - return DeviceParserFactory().getParser(.iosPhysical).parse(jsonString) - } - - static func getIOSSimulators() throws -> [Device] { - let output = try shellOut( - to: DeviceConstants.ProcessPaths.xcrun.rawValue, - arguments: ["simctl", "list", "devices", "available"] - ) - return DeviceParserFactory().getParser(.iosSimulator).parse(output) - } - - static func launchSimulatorApp(uuid: String) throws { - let isSimulatorRunning = NSWorkspace.shared.runningApplications - .contains { $0.bundleIdentifier == "com.apple.iphonesimulator" } - - if !isSimulatorRunning { - guard let activeDeveloperDir = try? shellOut( - to: DeviceConstants.ProcessPaths.xcodeSelect.rawValue, - arguments: ["-p"] - ) - .trimmingCharacters(in: .whitespacesAndNewlines) else { - throw DeviceError.xcodeError + for app in runningApps { + guard + let bundleURL = app.bundleURL?.absoluteString, + bundleURL.contains(DeviceConstants.BundleURL.simulator.rawValue) || + bundleURL.contains(DeviceConstants.BundleURL.emulator.rawValue) else { + continue } - try shellOut( - to: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator", - arguments: ["--args", "-CurrentDeviceUDID", uuid] - ) - } - } + let isAndroid = bundleURL.contains(DeviceConstants.BundleURL.emulator.rawValue) - private static func launchDevice(uuid: String) throws { - do { - try self.launchSimulatorApp(uuid: uuid) - try shellOut(to: DeviceConstants.ProcessPaths.xcrun.rawValue, arguments: ["simctl", "boot", uuid]) - } catch { - if !error.localizedDescription.contains(DeviceConstants.deviceBootedError) { - throw error - } - } - } - - static func deleteSimulator(uuid: String) throws { - Thread.assertBackgroundThread() - try shellOut(to: DeviceConstants.ProcessPaths.xcrun.rawValue, arguments: ["simctl", "delete", uuid]) - } - - static func handleiOSAction(device: Device, commandTag: SubMenuItems.Tags, itemName: String) { - queue.async { - switch commandTag { - case .copyName: - NSPasteboard.general.copyToPasteboard(text: device.name) - DeviceService.showSuccessMessage(title: "Device name copied to clipboard!", message: device.name) - case .copyID: - if let deviceID = device.identifier { - NSPasteboard.general.copyToPasteboard(text: deviceID) - DeviceService.showSuccessMessage(title: "Device ID copied to clipboard!", message: deviceID) + for window in AccessibilityElement.allWindowsForPID(app.processIdentifier) { + guard let windowTitle = window.attribute(key: .title, type: String.self), + !windowTitle.isEmpty else { + continue } - case .delete: - DispatchQueue.main.async { - guard let deviceID = device.identifier else { return } - let result = !NSAlert.showQuestionDialog( - title: "Are you sure?", - message: "Are you sure you want to delete this Simulator?" - ) - if result { return } - queue.async { - do { - try DeviceService.deleteSimulator(uuid: deviceID) - DeviceService.showSuccessMessage(title: "Simulator deleted!", message: deviceID) - NotificationCenter.default.post(name: .deviceDeleted, object: nil) - } catch { - NSAlert.showError(message: error.localizedDescription) - } - } - } - case .customCommand: - guard let command = CustomCommandService.getCustomCommand(platform: .ios, commandName: itemName) else { - return + if !matchDeviceTitle(windowTitle: windowTitle, device: device) { + continue } - do { - try CustomCommandService.runCustomCommand(device, command: command) - } catch { - NSAlert.showError(message: error.localizedDescription) + if isAndroid { + AccessibilityElement.forceFocus(pid: app.processIdentifier) + } else { + window.performAction(key: kAXRaiseAction) + app.activate(options: [.activateIgnoringOtherApps]) } - default: - break } } } -} - -// MARK: Android Methods -extension DeviceService { - private static func launchDevice(name: String, additionalArguments: [String] = []) throws { - Thread.assertBackgroundThread() - let emulatorPath = try ADB.getEmulatorPath() - var arguments = ["@\(name)"] - let formattedArguments = additionalArguments - .filter { !$0.isEmpty } - .map { $0.hasPrefix("-") ? $0 : "-\($0)" } - arguments.append(contentsOf: getAndroidLaunchParams()) - arguments.append(contentsOf: formattedArguments) - do { - try shellOut(to: emulatorPath, arguments: arguments) - } catch { - // Ignore force qutting emulator (CMD + Q) - if error.localizedDescription.contains("unexpected system image feature string") { - return - } - throw error - } - } - - private static func getAndroidLaunchParams() -> [String] { - guard let paramData = UserDefaults.standard.parameters else { return [] } - guard let parameters = try? JSONDecoder().decode([Parameter].self, from: paramData) else { - return [] - } - - return parameters.filter { $0.enabled } - .map { $0.command } - } - - static func getAndroidDevices() throws -> [Device] { - Thread.assertBackgroundThread() - let emulators = try getAndroidEmulators() - let devices = try getAndroidPhysicalDevices() - return emulators + devices - } - - static func getAndroidPhysicalDevices() throws -> [Device] { - let adbPath = try ADB.getAdbPath() - let output = try shellOut(to: adbPath, arguments: ["devices", "-l"]) - - return DeviceParserFactory().getParser(.androidPhysical).parse(output) - } - - static func getAndroidEmulators() throws -> [Device] { - let emulatorPath = try ADB.getEmulatorPath() - let output = try shellOut(to: emulatorPath, arguments: ["-list-avds"]) - - return DeviceParserFactory().getParser(.androidEmulator).parse(output) - } - - static func toggleA11y(device: Device) throws { - Thread.assertBackgroundThread() - - let adbPath = try ADB.getAdbPath() - guard let adbId = device.identifier else { - throw DeviceError.deviceNotFound - } - let a11yIsEnabled = ADB.isAccesibilityOn(deviceId: adbId) - let value = a11yIsEnabled ? ADB.talkbackOff : ADB.talkbackOn - let shellCmd = "\(adbPath) -s \(adbId) shell settings put secure enabled_accessibility_services \(value)" - _ = try? shellOut(to: shellCmd) - } - - static func sendText(device: Device, text: String) throws { - Thread.assertBackgroundThread() - let adbPath = try ADB.getAdbPath() - guard let deviceId = device.identifier else { - throw DeviceError.deviceNotFound - } - - let formattedText = text.replacingOccurrences(of: " ", with: "%s").replacingOccurrences(of: "'", with: "''") - - try shellOut(to: "\(adbPath) -s \(deviceId) shell input text \"\(formattedText)\"") - } - - static func deleteEmulator(device: Device) throws { - Thread.assertBackgroundThread() - let avdPath = try ADB.getAvdPath() - let adbPath = try ADB.getAdbPath() - if device.booted { - guard let deviceId = device.identifier else { - throw DeviceError.deviceNotFound - } - try shellOut(to: "\(adbPath) -s \(deviceId) emu kill") - } - try shellOut(to: "\(avdPath) delete avd -n \"\(device.name)\"") - } - - static func launchLogCat(device: Device) throws { - Thread.assertBackgroundThread() - guard let deviceId = device.identifier else { - throw DeviceError.deviceNotFound + private func matchDeviceTitle(windowTitle: String, device: Device) -> Bool { + if device.platform == .android { + let deviceName = windowTitle.match(#"(?<=- ).*?(?=:)"#).first?.first + return deviceName == device.name } - guard let adbPath = try? ADB.getAdbPath() else { return } - let logcatCommand = "\(adbPath) -s \(deviceId) logcat -v color" - try TerminalService.launchTerminal(command: logcatCommand) - } - - static func handleAndroidAction(device: Device, commandTag: SubMenuItems.Tags, itemName: String) { - queue.async { - do { - switch commandTag { - case .coldBoot: - try DeviceService.launchDevice(name: device.name, additionalArguments: ["-no-snapshot"]) - - case .noAudio: - try DeviceService.launchDevice(name: device.name, additionalArguments: ["-no-audio"]) - - case .toggleA11y: - try DeviceService.toggleA11y(device: device) - - case .copyID: - if let deviceId = device.identifier { - NSPasteboard.general.copyToPasteboard(text: deviceId) - DeviceService.showSuccessMessage(title: "Device ID copied to clipboard!", message: deviceId) - } - - case .copyName: - NSPasteboard.general.copyToPasteboard(text: device.name) - DeviceService.showSuccessMessage(title: "Device name copied to clipboard!", message: device.name) - - case .paste: - guard let clipboard = NSPasteboard.general.pasteboardItems?.first, - let text = clipboard.string(forType: .string) else { - break - } - try DeviceService.sendText(device: device, text: text) - - case .customCommand: - if let command = CustomCommandService.getCustomCommand(platform: .android, commandName: itemName) { - try CustomCommandService.runCustomCommand(device, command: command) - } - case .logcat: - try DeviceService.launchLogCat(device: device) + let deviceName = windowTitle.match(#"^[^–]*"#).first?.first?.trimmingCharacters(in: .whitespacesAndNewlines) - case .delete: - DispatchQueue.main.async { - let result = !NSAlert.showQuestionDialog( - title: "Are you sure?", - message: "Are you sure you want to delete this Emulator?" - ) - if result { return } - queue.async { - do { - try DeviceService.deleteEmulator(device: device) - DeviceService.showSuccessMessage(title: "Emulator deleted!", message: device.name) - NotificationCenter.default.post(name: .deviceDeleted, object: nil) - } catch { - NSAlert.showError(message: error.localizedDescription) - } - } - } - } - } catch { - NSAlert.showError(message: error.localizedDescription) - } - } + return deviceName == device.name } } diff --git a/MiniSim/Service/DeviceServiceFactory.swift b/MiniSim/Service/DeviceServiceFactory.swift new file mode 100644 index 0000000..c656dcb --- /dev/null +++ b/MiniSim/Service/DeviceServiceFactory.swift @@ -0,0 +1,56 @@ +import Foundation + +class DeviceServiceFactory { + private static let queue = DispatchQueue( + label: "com.MiniSim.DeviceService", + qos: .userInteractive, + attributes: .concurrent + ) + + static func getDeviceService(device: Device) -> DeviceServiceCommon { + switch device.platform { + case .ios: + return IOSDeviceService(device: device) + case .android: + return AndroidDeviceService(device: device) + } + } + + static func getDeviceDiscoveryService(platform: Platform) -> DeviceDiscoveryService { + switch platform { + case .ios: + return IOSDeviceDiscovery() + case .android: + return AndroidDeviceDiscovery() + } + } + + static func getAllDevices( + android: Bool, + iOS: Bool, + completionQueue: DispatchQueue = .main, + completion: @escaping ([Device], Error?) -> Void + ) { + queue.async { + do { + var devicesArray: [Device] = [] + + if android { + try devicesArray.append(contentsOf: AndroidDeviceDiscovery().getDevices()) + } + + if iOS { + try devicesArray.append(contentsOf: IOSDeviceDiscovery().getDevices()) + } + + completionQueue.async { + completion(devicesArray, nil) + } + } catch { + completionQueue.async { + completion([], error) + } + } + } + } +} diff --git a/MiniSim/Service/IOSDeviceService.swift b/MiniSim/Service/IOSDeviceService.swift new file mode 100644 index 0000000..4be3ab0 --- /dev/null +++ b/MiniSim/Service/IOSDeviceService.swift @@ -0,0 +1,30 @@ +import Foundation + +class IOSDeviceService: DeviceServiceCommon { + var shell: ShellProtocol = Shell() + var device: Device + + init(device: Device) { + self.device = device + } + + func deleteDevice() throws { + Thread.assertBackgroundThread() + if let uuid = device.identifier { + try shell.execute(command: DeviceConstants.ProcessPaths.xcrun.rawValue, arguments: ["simctl", "delete", uuid]) + } + } + + func launchDevice(additionalArgs: [String]) throws { + Thread.assertBackgroundThread() + do { + let uuid = device.identifier ?? "" + try AppleUtils.launchSimulatorApp(uuid: uuid) + try shell.execute(command: DeviceConstants.ProcessPaths.xcrun.rawValue, arguments: ["simctl", "boot", uuid]) + } catch { + if !error.localizedDescription.contains(DeviceConstants.deviceBootedError) { + throw error + } + } + } +} diff --git a/MiniSim/Views/Onboarding/SetupView.swift b/MiniSim/Views/Onboarding/SetupView.swift index 4cab9a2..09cefe2 100644 --- a/MiniSim/Views/Onboarding/SetupView.swift +++ b/MiniSim/Views/Onboarding/SetupView.swift @@ -26,12 +26,14 @@ struct SetupView: View { if !enableiOSSimulators { return } - isXcodeSetupCorrect = DeviceService.checkXcodeSetup() + isXcodeSetupCorrect = (try? IOSDeviceDiscovery().checkSetup()) != nil } func checkAndroidStudio() { do { - UserDefaults.standard.androidHome = try DeviceService.checkAndroidSetup() + if (try? AndroidDeviceDiscovery().checkSetup()) != nil { + UserDefaults.standard.androidHome = try ADB.getAndroidHome() + } } catch { isAndroidSetupCorrect = false } diff --git a/MiniSimTests/CustomCommandServiceTests.swift b/MiniSimTests/CustomCommandServiceTests.swift index a2c808f..0d91a96 100644 --- a/MiniSimTests/CustomCommandServiceTests.swift +++ b/MiniSimTests/CustomCommandServiceTests.swift @@ -5,6 +5,10 @@ class CustomCommandServiceTests: XCTestCase { class ADB: ADBProtocol { static var shell: ShellProtocol = Shell() + static func sendText(device: Device, text: String) throws {} + + static func launchLogCat(device: Device) throws {} + static func getAndroidHome() throws -> String { "mocked_android_home" } diff --git a/MiniSimTests/DeviceParserTests.swift b/MiniSimTests/DeviceParserTests.swift index 1fc5010..dc2bdaa 100644 --- a/MiniSimTests/DeviceParserTests.swift +++ b/MiniSimTests/DeviceParserTests.swift @@ -4,6 +4,12 @@ import XCTest class DeviceParserTests: XCTestCase { // Mock ADB class for testing class ADB: ADBProtocol { + static func sendText(device: Device, text: String) throws { + } + + static func launchLogCat(device: Device) throws { + } + static var shell: ShellProtocol = Shell() static func getAndroidHome() throws -> String { @@ -286,6 +292,12 @@ class DeviceParserTests: XCTestCase { func testAndroidEmulatorParserWithADBFailure() { class FailingADB: ADBProtocol { + static func sendText(device: Device, text: String) throws { + } + + static func launchLogCat(device: Device) throws { + } + static func getAndroidHome() throws -> String { "" }