From e056e84fc066130b09ff458bac4162939dd7dc76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 30 May 2024 13:53:46 +0200 Subject: [PATCH] fix: assert threads to prevent app freezing --- MiniSim.xcodeproj/project.pbxproj | 4 + MiniSim/Extensions/Thread+Asserts.swift | 11 + MiniSim/MainMenu.xib | 4 +- MiniSim/Service/DeviceService.swift | 858 ++++++++++++------------ 4 files changed, 454 insertions(+), 423 deletions(-) create mode 100644 MiniSim/Extensions/Thread+Asserts.swift diff --git a/MiniSim.xcodeproj/project.pbxproj b/MiniSim.xcodeproj/project.pbxproj index 195cc43..3e33935 100644 --- a/MiniSim.xcodeproj/project.pbxproj +++ b/MiniSim.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 52B363EE2AEC10B3006F515C /* ParametersTableFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B363ED2AEC10B3006F515C /* ParametersTableFormViewModel.swift */; }; 551B882A2B1385E900B8D325 /* Terminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551B88292B1385E900B8D325 /* Terminal.swift */; }; 55CDB0782B1B6D24002418D7 /* TerminalApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55CDB0772B1B6D24002418D7 /* TerminalApps.swift */; }; + 760554A32C085BEA001607FE /* Thread+Asserts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 760554A22C085BEA001607FE /* Thread+Asserts.swift */; }; 76059BF52AD4361C0008D38B /* SetupPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76059BF42AD4361C0008D38B /* SetupPreferences.swift */; }; 76059BF72AD449DC0008D38B /* OnboardingHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76059BF62AD449DC0008D38B /* OnboardingHeader.swift */; }; 76059BF92AD558C30008D38B /* SetupItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76059BF82AD558C30008D38B /* SetupItemView.swift */; }; @@ -104,6 +105,7 @@ 52B363ED2AEC10B3006F515C /* ParametersTableFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParametersTableFormViewModel.swift; sourceTree = ""; }; 551B88292B1385E900B8D325 /* Terminal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Terminal.swift; sourceTree = ""; }; 55CDB0772B1B6D24002418D7 /* TerminalApps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalApps.swift; sourceTree = ""; }; + 760554A22C085BEA001607FE /* Thread+Asserts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thread+Asserts.swift"; sourceTree = ""; }; 76059BF42AD4361C0008D38B /* SetupPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPreferences.swift; sourceTree = ""; }; 76059BF62AD449DC0008D38B /* OnboardingHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHeader.swift; sourceTree = ""; }; 76059BF82AD558C30008D38B /* SetupItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupItemView.swift; sourceTree = ""; }; @@ -231,6 +233,7 @@ 76E4451329D4403F00039025 /* NSNotificationName.swift */, 767F713129D574EF004159A6 /* UNUserNotificationCenter+showNotification.swift */, 4AFACC752AD73D7900EC369F /* NSMenuItem+ConvenienceInit.swift */, + 760554A22C085BEA001607FE /* Thread+Asserts.swift */, ); path = Extensions; sourceTree = ""; @@ -564,6 +567,7 @@ 76F2A912299033EA002D4EF6 /* DeviceError.swift in Sources */, 7631218E2A12B3BA00EE7F48 /* CustomCommandsViewModel.swift in Sources */, 4AFACC782AD74E9000EC369F /* DeviceListSection.swift in Sources */, + 760554A32C085BEA001607FE /* Thread+Asserts.swift in Sources */, 7677999E29C26264009030F8 /* PermissionsView.swift in Sources */, 76630F2B29C718F500FB64F9 /* AndroidPathInput.swift in Sources */, 767F713229D574EF004159A6 /* UNUserNotificationCenter+showNotification.swift in Sources */, diff --git a/MiniSim/Extensions/Thread+Asserts.swift b/MiniSim/Extensions/Thread+Asserts.swift new file mode 100644 index 0000000..d65f380 --- /dev/null +++ b/MiniSim/Extensions/Thread+Asserts.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Thread { + static func assertMainThread() { + precondition(Thread.isMainThread, "Not on main thread") + } + + static func assertBackgroundThread() { + precondition(!Thread.isMainThread, "On main thread") + } +} diff --git a/MiniSim/MainMenu.xib b/MiniSim/MainMenu.xib index a3982c1..6432769 100644 --- a/MiniSim/MainMenu.xib +++ b/MiniSim/MainMenu.xib @@ -1,8 +1,8 @@ - + - + diff --git a/MiniSim/Service/DeviceService.swift b/MiniSim/Service/DeviceService.swift index 50873b5..dbfdd9d 100644 --- a/MiniSim/Service/DeviceService.swift +++ b/MiniSim/Service/DeviceService.swift @@ -11,489 +11,505 @@ 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 - - static func focusDevice(_ device: Device) - static func runCustomCommand(_ device: Device, command: Command) throws - static func getCustomCommands(platform: Platform) -> [Command] - static func getCustomCommand(platform: Platform, commandName: String) -> Command? - static func showSuccessMessage(title: String, message: String) + 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 + + static func focusDevice(_ device: Device) + static func runCustomCommand(_ device: Device, command: Command) throws + static func getCustomCommands(platform: Platform) -> [Command] + static func getCustomCommand(platform: Platform, commandName: String) -> Command? + static func showSuccessMessage(title: String, message: String) } class DeviceService: DeviceServiceProtocol { - private static let queue = DispatchQueue( - label: "com.MiniSim.DeviceService", - qos: .userInteractive, - attributes: .concurrent - ) - private static let deviceBootedError = "Unable to boot device in current state: Booted" - - private static let derivedDataLocation = "~/Library/Developer/Xcode/DerivedData" - - private enum ProcessPaths: String { - case xcrun = "/usr/bin/xcrun" - case xcodeSelect = "/usr/bin/xcode-select" + private static let queue = DispatchQueue( + label: "com.MiniSim.DeviceService", + qos: .userInteractive, + attributes: .concurrent + ) + private static let deviceBootedError = "Unable to boot device in current state: Booted" + + private static let derivedDataLocation = "~/Library/Developer/Xcode/DerivedData" + + private enum ProcessPaths: String { + case xcrun = "/usr/bin/xcrun" + case xcodeSelect = "/usr/bin/xcode-select" + } + + private enum BundleURL: String { + case emulator = "qemu-system-aarch64" + case simulator = "Simulator.app" + } + + static func getCustomCommands(platform: Platform) -> [Command] { + guard let commandsData = UserDefaults.standard.commands else { return [] } + guard let commands = try? JSONDecoder().decode([Command].self, from: commandsData) else { + return [] } - private enum BundleURL: String { - case emulator = "qemu-system-aarch64" - case simulator = "Simulator.app" + return commands.filter { $0.platform == platform } + } + + static func getCustomCommand(platform: Platform, commandName: String) -> Command? { + let commands = getCustomCommands(platform: platform) + return commands.first { $0.name == commandName } + } + + static func runCustomCommand(_ device: Device, command: Command) throws { + Thread.assertBackgroundThread() + var commandToExecute = command.command + .replacingOccurrences(of: Variables.deviceName.rawValue, with: device.name) + + let deviceID = device.identifier ?? "" + + if command.platform == .android { + commandToExecute = try commandToExecute + .replacingOccurrences(of: Variables.adbPath.rawValue, with: ADB.getAdbPath()) + .replacingOccurrences(of: Variables.adbId.rawValue, with: deviceID) + .replacingOccurrences(of: Variables.androidHomePath.rawValue, with: ADB.getAndroidHome()) + } else { + commandToExecute = commandToExecute + .replacingOccurrences(of: Variables.uuid.rawValue, with: deviceID) + .replacingOccurrences(of: Variables.xcrunPath.rawValue, with: ProcessPaths.xcrun.rawValue) } - static func getCustomCommands(platform: Platform) -> [Command] { - guard let commandsData = UserDefaults.standard.commands else { return [] } - guard let commands = try? JSONDecoder().decode([Command].self, from: commandsData) else { - return [] + do { + try shellOut(to: commandToExecute) + if command.bootsDevice ?? false && command.platform == .ios { + try? launchSimulatorApp(uuid: deviceID) + } + NotificationCenter.default.post(name: .commandDidSucceed, object: nil) + } catch { + throw CustomCommandError.commandError(errorMessage: error.localizedDescription) + } + } + + 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(BundleURL.simulator.rawValue) || + bundleURL.contains(BundleURL.emulator.rawValue) else { + continue } - - return commands.filter { $0.platform == platform } + let isAndroid = bundleURL.contains(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]) + } + } + } } + } - static func getCustomCommand(platform: Platform, commandName: String) -> Command? { - let commands = getCustomCommands(platform: platform) - return commands.first { $0.name == commandName } + private static func matchDeviceTitle(windowTitle: String, device: Device) -> Bool { + if device.platform == .android { + let deviceName = windowTitle.match(#"(?<=- ).*?(?=:)"#).first?.first + return deviceName == device.name } - static func runCustomCommand(_ device: Device, command: Command) throws { - var commandToExecute = command.command - .replacingOccurrences(of: Variables.deviceName.rawValue, with: device.name) - - let deviceID = device.identifier ?? "" - - if command.platform == .android { - commandToExecute = try commandToExecute - .replacingOccurrences(of: Variables.adbPath.rawValue, with: ADB.getAdbPath()) - .replacingOccurrences(of: Variables.adbId.rawValue, with: deviceID) - .replacingOccurrences(of: Variables.androidHomePath.rawValue, with: ADB.getAndroidHome()) - } else { - commandToExecute = commandToExecute - .replacingOccurrences(of: Variables.uuid.rawValue, with: deviceID) - .replacingOccurrences(of: Variables.xcrunPath.rawValue, with: ProcessPaths.xcrun.rawValue) + let deviceName = windowTitle.match(#"^[^–]*"#).first?.first?.trimmingCharacters(in: .whitespacesAndNewlines) + + return deviceName == device.name + } + + static func checkXcodeSetup() -> Bool { + FileManager.default.fileExists(atPath: 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()) } - do { - try shellOut(to: commandToExecute) - if command.bootsDevice ?? false && command.platform == .ios { - try? launchSimulatorApp(uuid: deviceID) - } - NotificationCenter.default.post(name: .commandDidSucceed, object: nil) - } catch { - throw CustomCommandError.commandError(errorMessage: error.localizedDescription) + if iOS { + try devicesArray.append(contentsOf: getIOSDevices()) } - } - 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(BundleURL.simulator.rawValue) || - bundleURL.contains(BundleURL.emulator.rawValue) else { - continue - } - let isAndroid = bundleURL.contains(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]) - } - } - } + completionQueue.async { + completion(devicesArray, nil) + } + } catch { + completionQueue.async { + completion([], error) } + } } - - private static func matchDeviceTitle(windowTitle: String, device: Device) -> Bool { - if device.platform == .android { - let deviceName = windowTitle.match(#"(?<=- ).*?(?=:)"#).first?.first - return deviceName == device.name + } + + 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) + } + } + + 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) } - - let deviceName = windowTitle.match(#"^[^–]*"#).first?.first?.trimmingCharacters(in: .whitespacesAndNewlines) - - return deviceName == device.name + } catch { + if error.localizedDescription.contains(deviceBootedError) { + return + } + completionQueue.async { + completion(error) + } + } } + } +} - static func checkXcodeSetup() -> Bool { - FileManager.default.fileExists(atPath: ProcessPaths.xcrun.rawValue) +// MARK: iOS Methods +extension DeviceService { + private static func parseIOSDevices(result: [String]) -> [Device] { + var devices: [Device] = [] + let currentOSIdx = 1 + let deviceNameIdx = 1 + let identifierIdx = 4 + let deviceStateIdx = 5 + var osVersion = "" + result.forEach { line in + if let currentOs = line.match("-- (.*?) --").first, !currentOs.isEmpty { + osVersion = currentOs[currentOSIdx] + } + if let device = line.match("(.*?) (\\(([0-9.]+)\\) )?\\(([0-9A-F-]+)\\) (\\(.*?)\\)").first { + devices.append( + Device( + name: device[deviceNameIdx].trimmingCharacters(in: .whitespacesAndNewlines), + version: osVersion, + identifier: device[identifierIdx], + booted: device[deviceStateIdx].contains("Booted"), + platform: .ios + ) + ) + } } - - static func checkAndroidSetup() throws -> String { - let emulatorPath = try ADB.getAndroidHome() - try ADB.checkAndroidHome(path: emulatorPath) - return emulatorPath + return devices + } + + static func clearDerivedData( + completionQueue: DispatchQueue = .main, + completion: @escaping (String, Error?) -> Void + ) { + self.queue.async { + do { + let amountCleared = try? shellOut(to: "du -sh \(derivedDataLocation)") + .match(###"\d+\.?\d+\w+"###).first?.first + try shellOut(to: "rm -rf \(derivedDataLocation)") + completionQueue.async { + completion(amountCleared ?? "", nil) + } + } catch { + completionQueue.async { + completion("", error) + } + } } + } - static func showSuccessMessage(title: String, message: String) { - UNUserNotificationCenter.showNotification(title: title, body: message) - NotificationCenter.default.post(name: .commandDidSucceed, object: nil) + static func getIOSDevices() throws -> [Device] { + let output = try shellOut( + to: ProcessPaths.xcrun.rawValue, + arguments: ["simctl", "list", "devices", "available"] + ) + let splitted = output.components(separatedBy: "\n") + + return parseIOSDevices(result: splitted) + } + + 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: ProcessPaths.xcodeSelect.rawValue, + arguments: ["-p"] + ) + .trimmingCharacters(in: .whitespacesAndNewlines) else { + throw DeviceError.xcodeError + } + try shellOut( + to: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator", + arguments: ["--args", "-CurrentDeviceUDID", uuid] + ) } - - static func getAllDevices( - android: Bool, - iOS: Bool, - completionQueue: DispatchQueue = .main, - completion: @escaping ([Device], Error?) -> Void - ) { - queue.async { + } + + private static func launchDevice(uuid: String) throws { + do { + try self.launchSimulatorApp(uuid: uuid) + try shellOut(to: ProcessPaths.xcrun.rawValue, arguments: ["simctl", "boot", uuid]) + } catch { + if !error.localizedDescription.contains(deviceBootedError) { + throw error + } + } + } + + static func deleteSimulator(uuid: String) throws { + Thread.assertBackgroundThread() + try shellOut(to: 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) + } + 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 { - var devicesArray: [Device] = [] - - if android { - try devicesArray.append(contentsOf: getAndroidDevices()) - } - - if iOS { - try devicesArray.append(contentsOf: getIOSDevices()) - } - - completionQueue.async { - completion(devicesArray, nil) - } + try DeviceService.deleteSimulator(uuid: deviceID) + DeviceService.showSuccessMessage(title: "Simulator deleted!", message: deviceID) + NotificationCenter.default.post(name: .deviceDeleted, object: nil) } catch { - completionQueue.async { - completion([], error) - } + NSAlert.showError(message: error.localizedDescription) } + } } - } - - private static func launch(device: Device) throws { - switch device.platform { - case .ios: - try launchDevice(uuid: device.identifier ?? "") - case .android: - try launchDevice(name: device.name) + case .customCommand: + guard let command = DeviceService.getCustomCommand(platform: .ios, commandName: itemName) else { + return } - } - 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(deviceBootedError) { - return - } - completionQueue.async { - completion(error) - } - } + do { + try DeviceService.runCustomCommand(device, command: command) + } catch { + NSAlert.showError(message: error.localizedDescription) } + default: + break + } } + } } -// MARK: iOS Methods +// MARK: Android Methods extension DeviceService { - private static func parseIOSDevices(result: [String]) -> [Device] { - var devices: [Device] = [] - let currentOSIdx = 1 - let deviceNameIdx = 1 - let identifierIdx = 4 - let deviceStateIdx = 5 - var osVersion = "" - result.forEach { line in - if let currentOs = line.match("-- (.*?) --").first, !currentOs.isEmpty { - osVersion = currentOs[currentOSIdx] - } - if let device = line.match("(.*?) (\\(([0-9.]+)\\) )?\\(([0-9A-F-]+)\\) (\\(.*?)\\)").first { - devices.append( - Device( - name: device[deviceNameIdx].trimmingCharacters(in: .whitespacesAndNewlines), - version: osVersion, - identifier: device[identifierIdx], - booted: device[deviceStateIdx].contains("Booted"), - platform: .ios - ) - ) - } - } - return devices + 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 } + } - static func clearDerivedData( - completionQueue: DispatchQueue = .main, - completion: @escaping (String, Error?) -> Void - ) { - self.queue.async { - do { - let amountCleared = try? shellOut(to: "du -sh \(derivedDataLocation)") - .match(###"\d+\.?\d+\w+"###).first?.first - try shellOut(to: "rm -rf \(derivedDataLocation)") - completionQueue.async { - completion(amountCleared ?? "", nil) - } - } catch { - completionQueue.async { - completion("", 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 [] } - static func getIOSDevices() throws -> [Device] { - let output = try shellOut( - to: ProcessPaths.xcrun.rawValue, - arguments: ["simctl", "list", "devices", "available"] - ) - let splitted = output.components(separatedBy: "\n") + return parameters.filter { $0.enabled } + .map { $0.command } + } - return parseIOSDevices(result: splitted) - } + static func getAndroidDevices() throws -> [Device] { + Thread.assertBackgroundThread() - static func launchSimulatorApp(uuid: String) throws { - let isSimulatorRunning = NSWorkspace.shared.runningApplications - .contains { $0.bundleIdentifier == "com.apple.iphonesimulator" } + let emulatorPath = try ADB.getEmulatorPath() + let adbPath = try ADB.getAdbPath() + let output = try shellOut(to: emulatorPath, arguments: ["-list-avds"]) + let splitted = output.components(separatedBy: "\n") - if !isSimulatorRunning { - guard let activeDeveloperDir = try? shellOut( - to: ProcessPaths.xcodeSelect.rawValue, - arguments: ["-p"] - ) - .trimmingCharacters(in: .whitespacesAndNewlines) else { - throw DeviceError.xcodeError - } - try shellOut( - to: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator", - arguments: ["--args", "-CurrentDeviceUDID", uuid] - ) - } - } + return splitted + .filter { !$0.isEmpty } + .map { deviceName in + let adbId = try? ADB.getAdbId(for: deviceName, adbPath: adbPath) + return Device(name: deviceName, identifier: adbId, booted: adbId != nil, platform: .android) + } + } - private static func launchDevice(uuid: String) throws { - do { - try self.launchSimulatorApp(uuid: uuid) - try shellOut(to: ProcessPaths.xcrun.rawValue, arguments: ["simctl", "boot", uuid]) - } catch { - if !error.localizedDescription.contains(deviceBootedError) { - throw error - } - } - } + static func toggleA11y(device: Device) throws { + Thread.assertBackgroundThread() - static func deleteSimulator(uuid: String) throws { - try shellOut(to: ProcessPaths.xcrun.rawValue, arguments: ["simctl", "delete", uuid]) + let adbPath = try ADB.getAdbPath() + guard let adbId = device.identifier else { + throw DeviceError.deviceNotFound } - static func handleiOSAction(device: Device, commandTag: SubMenuItems.Tags, itemName: String) { - 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) - } - case .delete: - 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 = DeviceService.getCustomCommand(platform: .ios, commandName: itemName) else { - return - } - queue.async { - do { - try DeviceService.runCustomCommand(device, command: command) - } catch { - NSAlert.showError(message: error.localizedDescription) - } - } - default: - break - } + let a11yIsEnabled = ADB.isAccesibilityOn(deviceId: adbId, adbPath: adbPath) + 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 } -} -// MARK: Android Methods -extension DeviceService { - private static func launchDevice(name: String, additionalArguments: [String] = []) throws { - 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 - } - } + let formattedText = text.replacingOccurrences(of: " ", with: "%s").replacingOccurrences(of: "'", with: "''") - 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 [] - } + try shellOut(to: "\(adbPath) -s \(deviceId) shell input text \"\(formattedText)\"") + } - return parameters.filter { $0.enabled } - .map { $0.command } + 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 getAndroidDevices() throws -> [Device] { - let emulatorPath = try ADB.getEmulatorPath() - let adbPath = try ADB.getAdbPath() - let output = try shellOut(to: emulatorPath, arguments: ["-list-avds"]) - let splitted = output.components(separatedBy: "\n") - - return splitted - .filter { !$0.isEmpty } - .map { deviceName in - let adbId = try? ADB.getAdbId(for: deviceName, adbPath: adbPath) - return Device(name: deviceName, identifier: adbId, booted: adbId != nil, platform: .android) - } + static func launchLogCat(device: Device) throws { + Thread.assertBackgroundThread() + guard let deviceId = device.identifier else { + throw DeviceError.deviceNotFound } + guard let preferedTerminal = Terminal( + rawValue: UserDefaults.standard.preferedTerminal ?? Terminal.terminal.rawValue + ) + else { return } + let terminal = TerminalService.getTerminal(type: preferedTerminal) + try TerminalService.launchTerminal(terminal: terminal, deviceId: deviceId) + } + + 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"]) - static func toggleA11y(device: Device) throws { - let adbPath = try ADB.getAdbPath() - guard let adbId = device.identifier else { - throw DeviceError.deviceNotFound - } + case .noAudio: + try DeviceService.launchDevice(name: device.name, additionalArguments: ["-no-audio"]) - let a11yIsEnabled = ADB.isAccesibilityOn(deviceId: adbId, adbPath: adbPath) - let value = a11yIsEnabled ? ADB.talkbackOff : ADB.talkbackOn - let shellCmd = "\(adbPath) -s \(adbId) shell settings put secure enabled_accessibility_services \(value)" - _ = try? shellOut(to: shellCmd) - } + case .toggleA11y: + try DeviceService.toggleA11y(device: device) - static func sendText(device: Device, text: String) throws { - let adbPath = try ADB.getAdbPath() - guard let deviceId = device.identifier else { - throw DeviceError.deviceNotFound - } + case .copyID: + if let deviceId = device.identifier { + NSPasteboard.general.copyToPasteboard(text: deviceId) + DeviceService.showSuccessMessage(title: "Device ID copied to clipboard!", message: deviceId) + } - let formattedText = text.replacingOccurrences(of: " ", with: "%s").replacingOccurrences(of: "'", with: "''") + case .copyName: + NSPasteboard.general.copyToPasteboard(text: device.name) + DeviceService.showSuccessMessage(title: "Device name copied to clipboard!", message: device.name) - try shellOut(to: "\(adbPath) -s \(deviceId) shell input text \"\(formattedText)\"") - } + case .paste: + guard let clipboard = NSPasteboard.general.pasteboardItems?.first, + let text = clipboard.string(forType: .string) else { + break + } + try DeviceService.sendText(device: device, text: text) - static func deleteEmulator(device: Device) throws { - 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)\"") - } + case .customCommand: + if let command = DeviceService.getCustomCommand(platform: .android, commandName: itemName) { + try DeviceService.runCustomCommand(device, command: command) + } + case .logcat: + try DeviceService.launchLogCat(device: device) - static func launchLogCat(device: Device) throws { - guard let deviceId = device.identifier else { - throw DeviceError.deviceNotFound - } - guard let preferedTerminal = Terminal( - rawValue: UserDefaults.standard.preferedTerminal ?? Terminal.terminal.rawValue - ) - else { return } - let terminal = TerminalService.getTerminal(type: preferedTerminal) - try TerminalService.launchTerminal(terminal: terminal, deviceId: deviceId) - } - - static func handleAndroidAction(device: Device, commandTag: SubMenuItems.Tags, itemName: String) { - 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 = DeviceService.getCustomCommand(platform: .android, commandName: itemName) { - try DeviceService.runCustomCommand(device, command: command) - } - case .logcat: - try DeviceService.launchLogCat(device: device) - - case .delete: - 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) - } - } - - default: - break - } - } catch { + 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) + } } + } }