diff --git a/.gitignore b/.gitignore index b55c24e..cfa8250 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ fastlane/test_output iOSInjectionProject/ .DS_Store + +# Local build and packaging artifacts +dist/ diff --git a/MiniSim.xcodeproj/project.pbxproj b/MiniSim.xcodeproj/project.pbxproj index b339ea0..fa59bb2 100644 --- a/MiniSim.xcodeproj/project.pbxproj +++ b/MiniSim.xcodeproj/project.pbxproj @@ -101,7 +101,7 @@ 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 */; }; - 76F04A11298A5AE000BF9CA3 /* ADB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F04A10298A5AE000BF9CA3 /* ADB.swift */; }; + 76F04A11298A5AE000BF9CA3 /* Adb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F04A10298A5AE000BF9CA3 /* Adb.swift */; }; 76F04A14298A62CF00BF9CA3 /* ShellOut in Frameworks */ = {isa = PBXBuildFile; productRef = 76F04A13298A62CF00BF9CA3 /* ShellOut */; }; 76F269852A2A376A00424BDA /* CustomCommandError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F269842A2A376A00424BDA /* CustomCommandError.swift */; }; 76F269872A2A39D100424BDA /* Variables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F269862A2A39D100424BDA /* Variables.swift */; }; @@ -221,7 +221,7 @@ 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 = ""; }; - 76F04A10298A5AE000BF9CA3 /* ADB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ADB.swift; sourceTree = ""; }; + 76F04A10298A5AE000BF9CA3 /* Adb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Adb.swift; sourceTree = ""; }; 76F269842A2A376A00424BDA /* CustomCommandError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandError.swift; sourceTree = ""; }; 76F269862A2A39D100424BDA /* Variables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variables.swift; sourceTree = ""; }; 76F269892A2A40F700424BDA /* SectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeader.swift; sourceTree = ""; }; @@ -360,7 +360,7 @@ 76BF0AED2C905C43003BE568 /* IOSDeviceService.swift */, 76BF0AE92C905A94003BE568 /* AppleUtils.swift */, 7699511C2C845B1900462287 /* DeviceParser.swift */, - 76F04A10298A5AE000BF9CA3 /* ADB.swift */, + 76F04A10298A5AE000BF9CA3 /* Adb.swift */, 76B70F832B0D5AB4009D87A4 /* Shell.swift */, 76BF0ADE2C8E01B3003BE568 /* CustomCommandService.swift */, 76BF0AE02C8E027D003BE568 /* DeviceConstants.swift */, @@ -670,7 +670,7 @@ 76489D5A29BFC60C0070EF03 /* WelcomeView.swift in Sources */, 76F2698A2A2A40F700424BDA /* SectionHeader.swift in Sources */, 7645D4C22982CA9600019227 /* AppDelegate.swift in Sources */, - 76F04A11298A5AE000BF9CA3 /* ADB.swift in Sources */, + 76F04A11298A5AE000BF9CA3 /* Adb.swift in Sources */, 76730BB9298C1DF80019C680 /* DeviceMenuItem.swift in Sources */, 551B882A2B1385E900B8D325 /* Terminal.swift in Sources */, 767A9CC929C320ED00554193 /* OnboardingButton.swift in Sources */, diff --git a/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift b/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift index 365901c..3d47e98 100644 --- a/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift +++ b/MiniSim/AppleScript Commands/LaunchDeviceCommand.swift @@ -19,6 +19,7 @@ class LaunchDeviceCommand: NSScriptCommand { var devices: [Device] = [] try devices.append(contentsOf: DeviceServiceFactory.getDeviceDiscoveryService(platform: .ios).getDevices()) try devices.append(contentsOf: DeviceServiceFactory.getDeviceDiscoveryService(platform: .android).getDevices()) + try devices.append(contentsOf: DeviceServiceFactory.getDeviceDiscoveryService(platform: .harmony).getDevices()) guard let device = devices.first(where: { $0.name == deviceName }) else { scriptErrorNumber = NSInternalScriptError diff --git a/MiniSim/AppleScript Commands/MiniSim.sdef b/MiniSim/AppleScript Commands/MiniSim.sdef index ccf9a90..a5be47a 100644 --- a/MiniSim/AppleScript Commands/MiniSim.sdef +++ b/MiniSim/AppleScript Commands/MiniSim.sdef @@ -11,7 +11,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -44,7 +44,7 @@ - + diff --git a/MiniSim/Extensions/UserDefaults+Configuration.swift b/MiniSim/Extensions/UserDefaults+Configuration.swift index 4685bd3..1919524 100644 --- a/MiniSim/Extensions/UserDefaults+Configuration.swift +++ b/MiniSim/Extensions/UserDefaults+Configuration.swift @@ -15,6 +15,8 @@ extension UserDefaults { static let isOnboardingFinished = "isOnboardingFinished" static let enableiOSSimulators = "enableiOSSimulators" static let enableAndroidEmulators = "enableAndroidEmulators" + static let enableHarmonySimulators = "enableHarmonySimulators" + static let didMigrateHarmonySimulators = "didMigrateHarmonySimulators" static let preferedTerminal = "preferedTerminal" static let menuImage = "menuImage" } @@ -54,6 +56,11 @@ extension UserDefaults { set { set(newValue, forKey: Keys.enableAndroidEmulators) } } + public var enableHarmonySimulators: Bool { + get { bool(forKey: Keys.enableHarmonySimulators) } + set { set(newValue, forKey: Keys.enableHarmonySimulators) } + } + @objc public dynamic var preferedTerminal: String? { get { string(forKey: Keys.preferedTerminal) } set { set(newValue, forKey: Keys.preferedTerminal) } diff --git a/MiniSim/Info.plist b/MiniSim/Info.plist index b4b3423..f6f8f41 100644 --- a/MiniSim/Info.plist +++ b/MiniSim/Info.plist @@ -6,8 +6,16 @@ MiniSim.sdef NSAppleScriptEnabled + NSCameraUsageDescription + MiniSim uses the camera to provide camera input to the HarmonyOS simulator. + NSMicrophoneUsageDescription + MiniSim uses the microphone to provide audio input to the HarmonyOS simulator. SUFeedURL https://raw.githubusercontent.com/okwasniewski/MiniSim/main/appcast.xml + SUEnableAutomaticChecks + + SUAutomaticallyUpdate + CFBundleVersion $(CURRENT_PROJECT_VERSION) CFBundleShortVersionString diff --git a/MiniSim/Menu.swift b/MiniSim/Menu.swift index ba8c413..bd06896 100644 --- a/MiniSim/Menu.swift +++ b/MiniSim/Menu.swift @@ -35,6 +35,7 @@ class Menu: NSMenu { } func populateDefaultMenu() { + let mainMenuItems = items.filter { $0.target is MiniSim } var sections: [DeviceListSection] = [] sections.append(.iOSPhysical) @@ -47,7 +48,12 @@ class Menu: NSMenu { sections.append(.androidVirtual) } + if UserDefaults.standard.enableHarmonySimulators { + sections.append(.harmonyVirtual) + } + if sections.isEmpty { + self.items = mainMenuItems return } @@ -67,14 +73,15 @@ class Menu: NSMenu { menuItems.append(menuItem) menuItems.append(NSMenuItem.separator()) } - self.items = menuItems + self.items = menuItems + mainMenuItems } func updateDevicesList() { let userDefaults = UserDefaults.standard DeviceServiceFactory.getAllDevices( android: userDefaults.enableAndroidEmulators && userDefaults.androidHome != nil, - iOS: userDefaults.enableiOSSimulators + iOS: userDefaults.enableiOSSimulators, + harmony: userDefaults.enableHarmonySimulators ) { devices, error in if let error { NSAlert.showError(message: error.localizedDescription) @@ -118,6 +125,13 @@ class Menu: NSMenu { } do { try device.launch() + if device.platform == .harmony { + // DevEco boots asynchronously. Refresh the state after its + // window/process has had time to initialize. + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.updateDevicesList() + } + } } catch { NSAlert.showError(message: error.localizedDescription) } @@ -133,12 +147,14 @@ class Menu: NSMenu { let deviceItems = items.filter { !sections.contains($0.title) } let iosDeviceNames = devices.filter { $0.platform == Platform.ios }.map { $0.displayName } let androidDeviceNames = devices.filter { $0.platform == Platform.android }.map { $0.displayName } + let harmonyDeviceNames = devices.filter { $0.platform == Platform.harmony }.map { $0.displayName } let iosDevices = deviceItems.filter { iosDeviceNames.contains($0.title) } let androidDevices = deviceItems.filter { androidDeviceNames.contains($0.title) } assignKeyEquivalent(devices: iosDevices) assignKeyEquivalent(devices: androidDevices) + assignKeyEquivalent(devices: deviceItems.filter { harmonyDeviceNames.contains($0.title) }) } private func assignKeyEquivalent(devices: [NSMenuItem]) { @@ -183,6 +199,10 @@ class Menu: NSMenu { sections.append(.iOSVirtual) } sections.append(.iOSPhysical) + + if UserDefaults.standard.enableHarmonySimulators { + sections.append(.harmonyVirtual) + } return sections } @@ -197,6 +217,8 @@ class Menu: NSMenu { return device.platform == .android && device.type == .physical case .androidVirtual: return device.platform == .android && device.type == .virtual + case .harmonyVirtual: + return device.platform == .harmony && device.type == .virtual } } } @@ -225,17 +247,39 @@ class Menu: NSMenu { title: device.displayName, action: #selector(deviceItemClick), keyEquivalent: "", - type: device.platform == .ios ? .launchIOS : .launchAndroid, + type: menuItemType(for: device.platform), deviceFamily: device.deviceFamily ) menuItem.target = self - menuItem.keyEquivalentModifierMask = device.platform == .android ? [.option] : [.command] + menuItem.keyEquivalentModifierMask = keyEquivalentModifierMask(for: device.platform) menuItem.submenu = buildSubMenu(for: device) menuItem.state = device.booted ? .on : .off return menuItem } + private func menuItemType(for platform: Platform) -> DeviceMenuItem { + switch platform { + case .ios: + return .launchIOS + case .android: + return .launchAndroid + case .harmony: + return .launchHarmony + } + } + + private func keyEquivalentModifierMask(for platform: Platform) -> NSEvent.ModifierFlags { + switch platform { + case .ios: + return [.command] + case .android: + return [.option] + case .harmony: + return [.control] + } + } + private func replaceMenuItem(at index: Int, with newItem: NSMenuItem) { self.removeItem(at: index) self.insertItem(newItem, at: index) @@ -359,6 +403,8 @@ class Menu: NSMenu { extension Menu: NSMenuDelegate { func menuWillOpen(_ menu: NSMenu) { NotificationCenter.default.post(name: .menuWillOpen, object: nil) + // Rebuild section headers so preference changes take effect immediately. + self.populateDefaultMenu() self.updateDevicesList() KeyboardShortcuts.disable(.toggleMiniSim) } diff --git a/MiniSim/MenuItems/DeviceListSection.swift b/MiniSim/MenuItems/DeviceListSection.swift index 5ba29f0..b608105 100644 --- a/MiniSim/MenuItems/DeviceListSection.swift +++ b/MiniSim/MenuItems/DeviceListSection.swift @@ -12,6 +12,7 @@ enum DeviceListSection: Int, CaseIterable { case iOSVirtual case androidPhysical case androidVirtual + case harmonyVirtual var title: String { switch self { @@ -23,6 +24,8 @@ enum DeviceListSection: Int, CaseIterable { return NSLocalizedString("Android Emulators", comment: "") case .androidPhysical: return NSLocalizedString("Android Devices", comment: "") + case .harmonyVirtual: + return NSLocalizedString("HarmonyOS Simulators", comment: "") } } } diff --git a/MiniSim/MenuItems/DeviceMenuItem.swift b/MiniSim/MenuItems/DeviceMenuItem.swift index 64205d9..d09205c 100644 --- a/MiniSim/MenuItems/DeviceMenuItem.swift +++ b/MiniSim/MenuItems/DeviceMenuItem.swift @@ -13,4 +13,7 @@ enum DeviceMenuItem: Int { // iOS case launchIOS = 300 + + // HarmonyOS + case launchHarmony = 400 } diff --git a/MiniSim/MenuItems/SubMenuItem.swift b/MiniSim/MenuItems/SubMenuItem.swift index 3225868..1010b56 100644 --- a/MiniSim/MenuItems/SubMenuItem.swift +++ b/MiniSim/MenuItems/SubMenuItem.swift @@ -271,6 +271,20 @@ extension SubMenuItems { DeleteEmulator() ] + case (.harmony, .virtual): + return [ + CopyName(), + CopyID(), + + Separator(), + + DeleteEmulator() + ] + case (.harmony, .physical): + return [ + CopyName(), + CopyID() + ] } } } diff --git a/MiniSim/MiniSim.swift b/MiniSim/MiniSim.swift index 961a420..adcf4f0 100644 --- a/MiniSim/MiniSim.swift +++ b/MiniSim/MiniSim.swift @@ -121,8 +121,20 @@ class MiniSim: NSObject { UserDefaults.standard.register(defaults: [ UserDefaults.Keys.enableAndroidEmulators: true, UserDefaults.Keys.enableiOSSimulators: true, + UserDefaults.Keys.enableHarmonySimulators: true, UserDefaults.Keys.preferedTerminal: "Terminal" ]) + + // Older builds initialized this new option to false. Migrate that value + // once so existing installations can discover HarmonyOS automatically; + // subsequent user changes are preserved. + if UserDefaults.standard.object(forKey: UserDefaults.Keys.didMigrateHarmonySimulators) == nil { + UserDefaults.standard.enableHarmonySimulators = true + UserDefaults.standard.set( + true, + forKey: UserDefaults.Keys.didMigrateHarmonySimulators + ) + } } private func setMenuImage() { diff --git a/MiniSim/Model/Device.swift b/MiniSim/Model/Device.swift index daed423..5053433 100644 --- a/MiniSim/Model/Device.swift +++ b/MiniSim/Model/Device.swift @@ -19,6 +19,12 @@ struct Device: Hashable, Codable { case .android: return name + + case .harmony: + if let version { + return "\(name) - (\(version))" + } + return name } } diff --git a/MiniSim/Model/Platform.swift b/MiniSim/Model/Platform.swift index 44c03ee..c008a8b 100644 --- a/MiniSim/Model/Platform.swift +++ b/MiniSim/Model/Platform.swift @@ -10,6 +10,7 @@ import Foundation enum Platform: String, Codable { case ios case android + case harmony } // TODO: Remove this type during migration to CoreData. diff --git a/MiniSim/Model/Variables.swift b/MiniSim/Model/Variables.swift index 2b763e7..3b55b54 100644 --- a/MiniSim/Model/Variables.swift +++ b/MiniSim/Model/Variables.swift @@ -19,6 +19,9 @@ enum Variables: String { case uuid = "$uuid" case xcrunPath = "$xcrun_path" + // HarmonyOS Specific + case harmonyEmulatorPath = "$harmony_emulator_path" + static var common: [Variables] { [deviceName] } @@ -31,6 +34,10 @@ enum Variables: String { [uuid, xcrunPath] } + static var harmony: [Variables] { + [harmonyEmulatorPath] + } + var description: String { switch self { case .deviceName: @@ -45,6 +52,8 @@ enum Variables: String { return NSLocalizedString("Unique identifier of iOS simulator", comment: "") case .xcrunPath: return NSLocalizedString("Path to xcrun utility", comment: "") + case .harmonyEmulatorPath: + return NSLocalizedString("Path to the DevEco Studio emulator utility", comment: "") } } } diff --git a/MiniSim/Service/ActionExecutor.swift b/MiniSim/Service/ActionExecutor.swift index 212d278..868e151 100644 --- a/MiniSim/Service/ActionExecutor.swift +++ b/MiniSim/Service/ActionExecutor.swift @@ -31,6 +31,13 @@ class ActionExecutor { itemName: itemName, skipConfirmation: skipConfirmation ) + case .harmony: + action = HarmonyActionFactory.createAction( + for: commandTag, + device: device, + itemName: itemName, + skipConfirmation: skipConfirmation + ) } if action.showQuestionDialog() { diff --git a/MiniSim/Service/ActionFactory.swift b/MiniSim/Service/ActionFactory.swift index 6b67472..dea31ea 100644 --- a/MiniSim/Service/ActionFactory.swift +++ b/MiniSim/Service/ActionFactory.swift @@ -56,3 +56,25 @@ class IOSActionFactory: ActionFactory { } } } + +final class HarmonyActionFactory: ActionFactory { + static func createAction( + for tag: SubMenuItems.Tags, + device: Device, + itemName: String, + skipConfirmation: Bool = false + ) -> any Action { + switch tag { + case .copyName: + return CopyNameAction(device: device) + case .copyID: + return CopyIDAction(device: device) + case .delete: + return DeleteAction(device: device, skipConfirmation: skipConfirmation) + case .customCommand: + return CustomCommandAction(device: device, itemName: itemName) + default: + return UnsupportedAction(message: "This action is not available for HarmonyOS simulators.") + } + } +} diff --git a/MiniSim/Service/AndroidDeviceService.swift b/MiniSim/Service/AndroidDeviceService.swift index b866c36..6d38664 100644 --- a/MiniSim/Service/AndroidDeviceService.swift +++ b/MiniSim/Service/AndroidDeviceService.swift @@ -52,3 +52,57 @@ class AndroidDeviceService: DeviceServiceCommon { .map { $0.command } } } + +final class HarmonyDeviceService: DeviceServiceCommon { + var shell: ShellProtocol = Shell() + var device: Device + + init(device: Device) { + self.device = device + } + + func deleteDevice() throws { + Thread.assertBackgroundThread() + let emulatorPath = try HarmonyDeviceTool.getEmulatorPath() + try shell.execute(command: emulatorPath, arguments: ["-delete", device.name]) + } + + func launchDevice(additionalArgs: [String]) throws { + Thread.assertBackgroundThread() + let emulatorPath = try HarmonyDeviceTool.getEmulatorPath() + + // The menu can contain a slightly stale snapshot of the device state. + // Check DevEco again before starting to avoid launching a second instance. + if let output = try? shell.execute(command: emulatorPath, arguments: ["-list", "-details"]), + HarmonySimulatorParser().parse(output).contains(where: { $0.name == device.name && $0.booted }) { + return + } + + do { + let launchPath = try HarmonyDeviceTool.getLaunchEmulatorPath(sourcePath: emulatorPath) + try HarmonyDeviceTool.launch( + path: launchPath, + arguments: HarmonyDeviceTool.launchArguments( + for: device.name, + additionalArgs: additionalArgs + ), + deviceName: device.name + ) + } catch { + throw DeviceError.harmonyEmulatorLaunchFailed + } + } + + func focusDevice() { + Thread.assertBackgroundThread() + + let runningApplications = NSWorkspace.shared.runningApplications + let emulatorApplication = runningApplications.first { application in + guard let bundlePath = application.bundleURL?.path else { return false } + return bundlePath.contains("DevEco-Studio.app") + || bundlePath.contains("/HarmonyEmulator.app") + || bundlePath.hasSuffix("/Emulator") + } + emulatorApplication?.activate(options: [.activateIgnoringOtherApps]) + } +} diff --git a/MiniSim/Service/CustomCommandService.swift b/MiniSim/Service/CustomCommandService.swift index 20db351..ec517c3 100644 --- a/MiniSim/Service/CustomCommandService.swift +++ b/MiniSim/Service/CustomCommandService.swift @@ -28,21 +28,31 @@ class CustomCommandService { let deviceID = device.identifier ?? "" - if command.platform == .android { + switch command.platform { + case .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 { + case .ios: commandToExecute = commandToExecute .replacingOccurrences(of: Variables.uuid.rawValue, with: deviceID) .replacingOccurrences(of: Variables.xcrunPath.rawValue, with: DeviceConstants.ProcessPaths.xcrun.rawValue) + case .harmony: + commandToExecute = try commandToExecute.replacingOccurrences( + of: Variables.harmonyEmulatorPath.rawValue, + with: HarmonyDeviceTool.getEmulatorPath() + ) } do { try shell.execute(command: commandToExecute) - if command.bootsDevice ?? false && command.platform == .ios { - try? AppleUtils.launchSimulatorApp(uuid: deviceID) + if command.bootsDevice ?? false { + if command.platform == .ios { + try? AppleUtils.launchSimulatorApp(uuid: deviceID) + } else if command.platform == .harmony { + try? device.launch() + } } NotificationCenter.default.post(name: .commandDidSucceed, object: nil) } catch { diff --git a/MiniSim/Service/CustomErrors/DeviceError.swift b/MiniSim/Service/CustomErrors/DeviceError.swift index 07174ae..2341c83 100644 --- a/MiniSim/Service/CustomErrors/DeviceError.swift +++ b/MiniSim/Service/CustomErrors/DeviceError.swift @@ -16,6 +16,11 @@ enum DeviceError: Error, Equatable { // Throw when there was an error with Android command / configuration case androidStudioError + // Throw when there was an error with DevEco Studio command / configuration + case harmonyStudioError + + case harmonyEmulatorLaunchFailed + // Throw in all other cases case unexpected(code: Int) } @@ -46,6 +51,16 @@ extension DeviceError: LocalizedError { """, comment: "Android Studio error" ) + case .harmonyStudioError: + return NSLocalizedString( + "DevEco Studio emulator was not found. Install DevEco Studio or disable HarmonyOS simulators in Preferences.", + comment: "DevEco Studio error" + ) + case .harmonyEmulatorLaunchFailed: + return NSLocalizedString( + "The HarmonyOS simulator could not be started. MiniSim could not prepare its direct-launch helper bundle.", + comment: "HarmonyOS emulator launch error" + ) case .unexpected: return NSLocalizedString( "An unexpected error occurred.", diff --git a/MiniSim/Service/DeviceConstants.swift b/MiniSim/Service/DeviceConstants.swift index eaebce5..048d97c 100644 --- a/MiniSim/Service/DeviceConstants.swift +++ b/MiniSim/Service/DeviceConstants.swift @@ -7,6 +7,7 @@ enum DeviceConstants { enum ProcessPaths: String { case xcrun = "/usr/bin/xcrun" case xcodeSelect = "/usr/bin/xcode-select" + case harmonyEmulator = "/Applications/DevEco-Studio.app/Contents/tools/emulator/Emulator" } enum BundleURL: String { diff --git a/MiniSim/Service/DeviceDiscoveryService.swift b/MiniSim/Service/DeviceDiscoveryService.swift index e29b9b0..432c6b2 100644 --- a/MiniSim/Service/DeviceDiscoveryService.swift +++ b/MiniSim/Service/DeviceDiscoveryService.swift @@ -94,3 +94,239 @@ class IOSDeviceDiscovery: DeviceDiscoveryService { FileManager.default.fileExists(atPath: DeviceConstants.ProcessPaths.xcrun.rawValue) } } + +final class HarmonyDeviceTool { + private static let processLock = NSLock() + private static var activeProcesses: [String: Process] = [:] + + static func getEmulatorPath( + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) throws -> String { + var candidates = [ + DeviceConstants.ProcessPaths.harmonyEmulator.rawValue, + "\(homeDirectory)/Applications/DevEco-Studio.app/Contents/tools/emulator/Emulator" + ] + + for applicationsDirectory in ["/Applications", "\(homeDirectory)/Applications"] { + let applicationPaths = (try? fileManager.contentsOfDirectory(atPath: applicationsDirectory)) ?? [] + candidates.append(contentsOf: applicationPaths + .filter { $0.lowercased().hasPrefix("deveco-studio") && $0.hasSuffix(".app") } + .map { "\(applicationsDirectory)/\($0)/Contents/tools/emulator/Emulator" }) + } + + guard let path = candidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) else { + throw DeviceError.harmonyStudioError + } + + return path + } + + /// The DevEco emulator is distributed as a bare executable. On recent macOS + /// versions that executable can be terminated by TCC when it opens the host + /// camera because it has no containing app bundle with a usage description. + /// Build a private app bundle for the executable so MiniSim can still launch + /// the emulator directly without opening DevEco Studio. + static func getLaunchEmulatorPath( + sourcePath: String, + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) throws -> String { + try HarmonyEmulatorBundle.executablePath( + sourcePath: sourcePath, + fileManager: fileManager, + homeDirectory: homeDirectory + ) + } + + static func launchArguments(for deviceName: String, additionalArgs: [String] = []) -> [String] { + ["-start", deviceName, "-bootmode", "coldboot_no_save"] + additionalArgs + } + + /// Starts DevEco's emulator as an independent process. The emulator remains + /// alive after this method returns, so its later shutdown does not become a + /// ShellOut error in MiniSim. + static func launch(path: String, arguments: [String], deviceName: String) throws { + processLock.lock() + if activeProcesses[deviceName] != nil { + processLock.unlock() + return + } + + let process = Process() + activeProcesses[deviceName] = process + processLock.unlock() + + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.terminationHandler = { _ in + processLock.lock() + activeProcesses.removeValue(forKey: deviceName) + processLock.unlock() + } + + do { + try process.run() + } catch { + processLock.lock() + activeProcesses.removeValue(forKey: deviceName) + processLock.unlock() + throw error + } + } +} + +private enum HarmonyEmulatorBundle { + private static let bundleName = "HarmonyEmulator.app" + private static let bundleIdentifier = "com.oskarkwasniewski.MiniSim.HarmonyEmulator" + + static func executablePath( + sourcePath: String, + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) throws -> String { + let applicationSupport = URL(fileURLWithPath: homeDirectory) + .appendingPathComponent("Library/Application Support/MiniSim", isDirectory: true) + let bundleURL = applicationSupport.appendingPathComponent(bundleName, isDirectory: true) + let executableURL = bundleURL.appendingPathComponent("Contents/MacOS/Emulator") + let sourceURL = URL(fileURLWithPath: sourcePath) + + guard fileManager.isExecutableFile(atPath: sourcePath) else { + throw DeviceError.harmonyStudioError + } + + if !isCurrentBundle( + bundleURL: bundleURL, + executableURL: executableURL, + sourceURL: sourceURL, + fileManager: fileManager + ) { + try installBundle( + bundleURL: bundleURL, + sourceURL: sourceURL, + applicationSupport: applicationSupport, + fileManager: fileManager + ) + } + + guard fileManager.isExecutableFile(atPath: executableURL.path) else { + throw DeviceError.harmonyStudioError + } + return executableURL.path + } + + private static func infoDictionary() -> [String: String] { + [ + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": "MiniSim HarmonyOS Emulator", + "CFBundleExecutable": "Emulator", + "CFBundleIdentifier": bundleIdentifier, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "MiniSim HarmonyOS Emulator", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "12.0", + "NSCameraUsageDescription": "MiniSim uses the camera to provide camera input to the HarmonyOS simulator.", + "NSMicrophoneUsageDescription": "MiniSim uses the microphone to provide audio input to the HarmonyOS simulator." + ] + } + + private static func isCurrentBundle( + bundleURL: URL, + executableURL: URL, + sourceURL: URL, + fileManager: FileManager + ) -> Bool { + guard fileManager.isExecutableFile(atPath: executableURL.path) else { return false } + + let markerURL = bundleURL.appendingPathComponent("Contents/.minisim-source") + guard let marker = try? String(contentsOf: markerURL, encoding: .utf8) else { return false } + guard let sourceAttributes = try? fileManager.attributesOfItem(atPath: sourceURL.path), + let sourceSize = sourceAttributes[.size] as? NSNumber, + let sourceDate = sourceAttributes[.modificationDate] as? Date else { + return false + } + + let expectedMarker = "\(sourceURL.path)\n\(sourceSize.int64Value)\n\(sourceDate.timeIntervalSince1970)\n" + return marker == expectedMarker + } + + private static func installBundle( + bundleURL: URL, + sourceURL: URL, + applicationSupport: URL, + fileManager: FileManager + ) throws { + try fileManager.createDirectory(at: applicationSupport, withIntermediateDirectories: true) + + let temporaryBundle = applicationSupport + .appendingPathComponent(".\(bundleName).\(UUID().uuidString)", isDirectory: true) + let contentsURL = temporaryBundle.appendingPathComponent("Contents", isDirectory: true) + let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true) + + do { + try fileManager.createDirectory(at: macOSURL, withIntermediateDirectories: true) + // Keep the original DevEco layout beside the executable. This preserves + // Qt plugin lookup and the emulator's @loader_path run paths. + let sourceDirectory = sourceURL.deletingLastPathComponent() + let sourceItems = try fileManager.contentsOfDirectory( + at: sourceDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + for sourceItem in sourceItems { + try fileManager.copyItem( + at: sourceItem, + to: macOSURL.appendingPathComponent(sourceItem.lastPathComponent) + ) + } + + let plistURL = contentsURL.appendingPathComponent("Info.plist") + let plistData = try PropertyListSerialization.data( + fromPropertyList: infoDictionary(), + format: .xml, + options: 0 + ) + try plistData.write(to: plistURL, options: .atomic) + + let sourceAttributes = try fileManager.attributesOfItem(atPath: sourceURL.path) + let sourceSize = (sourceAttributes[.size] as? NSNumber)?.int64Value ?? 0 + let sourceDate = (sourceAttributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let marker = "\(sourceURL.path)\n\(sourceSize)\n\(sourceDate)\n" + try marker.write( + to: contentsURL.appendingPathComponent(".minisim-source"), + atomically: true, + encoding: .utf8 + ) + + if fileManager.fileExists(atPath: bundleURL.path) { + try fileManager.removeItem(at: bundleURL) + } + try fileManager.moveItem(at: temporaryBundle, to: bundleURL) + } catch { + try? fileManager.removeItem(at: temporaryBundle) + throw error + } + } +} + +final class HarmonyDeviceDiscovery: DeviceDiscoveryService { + var shell: ShellProtocol = Shell() + + func getDevices(type: DeviceType? = nil) throws -> [Device] { + guard type != .physical else { return [] } + + let emulatorPath = try HarmonyDeviceTool.getEmulatorPath() + let output = try shell.execute(command: emulatorPath, arguments: ["-list", "-details"]) + return DeviceParserFactory().getParser(.harmonySimulator).parse(output) + } + + func checkSetup() throws -> Bool { + _ = try HarmonyDeviceTool.getEmulatorPath() + return true + } +} diff --git a/MiniSim/Service/DeviceParser.swift b/MiniSim/Service/DeviceParser.swift index 2371395..2e32140 100644 --- a/MiniSim/Service/DeviceParser.swift +++ b/MiniSim/Service/DeviceParser.swift @@ -5,6 +5,7 @@ enum DeviceParserType { case iosPhysical case androidEmulator case androidPhysical + case harmonySimulator } protocol DeviceParser { @@ -22,6 +23,8 @@ class DeviceParserFactory { return AndroidEmulatorParser() case .androidPhysical: return AndroidPhysicalDeviceParser() + case .harmonySimulator: + return HarmonySimulatorParser() } } } @@ -180,3 +183,47 @@ class AndroidPhysicalDeviceParser: DeviceParser { } } } + +final class HarmonySimulatorParser: DeviceParser { + func parse(_ input: String) -> [Device] { + guard let data = input.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } + + return values.compactMap { value in + guard let name = value["name"] as? String, !name.isEmpty else { + return nil + } + + let identifier = value["uuid"] as? String + let version = value["os.osVersion"] as? String ?? value["os.softwareVersion"] as? String + let booted = parseRunningValue(value["isRunning"]) + + return Device( + name: name, + version: version, + identifier: identifier, + booted: booted, + platform: .harmony, + type: .virtual + ) + } + } + + private func parseRunningValue(_ value: Any?) -> Bool { + if let boolValue = value as? Bool { + return boolValue + } + + if let stringValue = value as? String { + return stringValue.lowercased() == "true" + } + + if let numberValue = value as? NSNumber { + return numberValue.boolValue + } + + return false + } +} diff --git a/MiniSim/Service/DeviceServiceFactory.swift b/MiniSim/Service/DeviceServiceFactory.swift index c656dcb..bd51872 100644 --- a/MiniSim/Service/DeviceServiceFactory.swift +++ b/MiniSim/Service/DeviceServiceFactory.swift @@ -13,6 +13,8 @@ class DeviceServiceFactory { return IOSDeviceService(device: device) case .android: return AndroidDeviceService(device: device) + case .harmony: + return HarmonyDeviceService(device: device) } } @@ -22,12 +24,15 @@ class DeviceServiceFactory { return IOSDeviceDiscovery() case .android: return AndroidDeviceDiscovery() + case .harmony: + return HarmonyDeviceDiscovery() } } static func getAllDevices( android: Bool, iOS: Bool, + harmony: Bool = false, completionQueue: DispatchQueue = .main, completion: @escaping ([Device], Error?) -> Void ) { @@ -43,6 +48,12 @@ class DeviceServiceFactory { try devicesArray.append(contentsOf: IOSDeviceDiscovery().getDevices()) } + if harmony { + // HarmonyOS support is optional; an unavailable DevEco installation + // should not hide otherwise usable iOS or Android devices. + try? devicesArray.append(contentsOf: HarmonyDeviceDiscovery().getDevices()) + } + completionQueue.async { completion(devicesArray, nil) } diff --git a/MiniSim/Views/About.swift b/MiniSim/Views/About.swift index 91d6a34..5c2dacb 100644 --- a/MiniSim/Views/About.swift +++ b/MiniSim/Views/About.swift @@ -16,7 +16,10 @@ struct About: View { init() { updaterController = SPUStandardUpdaterController( - startingUpdater: true, + // Do not start Sparkle while the About view is being created. Starting + // it schedules automatic checks and can trigger macOS App Management + // authorization for the updater installer on every app launch. + startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil ) @@ -37,6 +40,7 @@ struct About: View { .padding(.bottom, bottomPadding) } Button { + updaterController.startUpdater() updaterController.updater.checkForUpdates() } label: { Label("Check for updates", systemImage: "gear") diff --git a/MiniSim/Views/CustomCommands/CustomCommandForm.swift b/MiniSim/Views/CustomCommands/CustomCommandForm.swift index ede5c92..9ad9efa 100644 --- a/MiniSim/Views/CustomCommands/CustomCommandForm.swift +++ b/MiniSim/Views/CustomCommands/CustomCommandForm.swift @@ -78,6 +78,7 @@ struct CustomCommandForm: View { Picker("Platform", selection: $viewModel.platform) { Text("iOS").tag(Platform.ios) Text("Android").tag(Platform.android) + Text("HarmonyOS").tag(Platform.harmony) } Toggle(isOn: $viewModel.needsBootedDevice) { diff --git a/MiniSim/Views/CustomCommands/CustomCommandFormViewModel.swift b/MiniSim/Views/CustomCommands/CustomCommandFormViewModel.swift index 8780c26..c320c84 100644 --- a/MiniSim/Views/CustomCommands/CustomCommandFormViewModel.swift +++ b/MiniSim/Views/CustomCommands/CustomCommandFormViewModel.swift @@ -63,7 +63,14 @@ extension CustomCommandForm { func updateAvailableVariables() { var variables = Variables.common - variables.append(contentsOf: platform == .ios ? Variables.ios : Variables.android) + switch platform { + case .ios: + variables.append(contentsOf: Variables.ios) + case .android: + variables.append(contentsOf: Variables.android) + case .harmony: + variables.append(contentsOf: Variables.harmony) + } availableVariables = variables if needsBootedDevice && platform == .android { availableVariables.append(Variables.adbId) diff --git a/MiniSim/Views/CustomCommands/CustomCommands.swift b/MiniSim/Views/CustomCommands/CustomCommands.swift index 5d5afbf..ece299e 100644 --- a/MiniSim/Views/CustomCommands/CustomCommands.swift +++ b/MiniSim/Views/CustomCommands/CustomCommands.swift @@ -20,6 +20,7 @@ struct CustomCommands: View { Picker("", selection: $viewModel.selectedPlatform) { Text("iOS").tag(Platform.ios) Text("Android").tag(Platform.android) + Text("HarmonyOS").tag(Platform.harmony) } .pickerStyle(SegmentedPickerStyle()) .padding(.vertical, 3) diff --git a/MiniSim/Views/Onboarding/SetupPreferences.swift b/MiniSim/Views/Onboarding/SetupPreferences.swift index c8458bb..5303906 100644 --- a/MiniSim/Views/Onboarding/SetupPreferences.swift +++ b/MiniSim/Views/Onboarding/SetupPreferences.swift @@ -11,6 +11,7 @@ struct SetupPreferences: View { var goToNextPage: () -> Void @AppStorage(UserDefaults.Keys.enableiOSSimulators, store: .standard) var enableiOSSimulators = true @AppStorage(UserDefaults.Keys.enableAndroidEmulators, store: .standard) var enableAndroidEmulators = true + @AppStorage(UserDefaults.Keys.enableHarmonySimulators, store: .standard) var enableHarmonySimulators = true var body: some View { VStack { @@ -19,7 +20,7 @@ struct SetupPreferences: View { title: "Tooling ⚙️", subTitle: """ If you want to use Minisim for launching only Android or - only iOS simulators you can tweak it here. + only iOS or HarmonyOS simulators you can tweak it here. """ ) Spacer() @@ -34,9 +35,14 @@ struct SetupPreferences: View { .labelsHidden() .toggleStyle(.switch) } + SetupItemView(imageName: "box", title: "DevEco Studio", subTitle: "HarmonyOS Simulators") { + Toggle("", isOn: $enableHarmonySimulators) + .labelsHidden() + .toggleStyle(.switch) + } } Spacer() - if enableiOSSimulators || enableAndroidEmulators { + if enableiOSSimulators || enableAndroidEmulators || enableHarmonySimulators { OnboardingButton("Continue", action: goToNextPage) } diff --git a/MiniSim/Views/Onboarding/SetupView.swift b/MiniSim/Views/Onboarding/SetupView.swift index 09cefe2..9a31d66 100644 --- a/MiniSim/Views/Onboarding/SetupView.swift +++ b/MiniSim/Views/Onboarding/SetupView.swift @@ -12,14 +12,17 @@ struct SetupView: View { @State private var isLoading = true @State private var isXcodeSetupCorrect = true @State private var isAndroidSetupCorrect = true + @State private var isHarmonySetupCorrect = true @AppStorage(UserDefaults.Keys.enableiOSSimulators, store: .standard) var enableiOSSimulators = true @AppStorage(UserDefaults.Keys.enableAndroidEmulators, store: .standard) var enableAndroidEmulators = true + @AppStorage(UserDefaults.Keys.enableHarmonySimulators, store: .standard) var enableHarmonySimulators = true var canContinue: Bool { let enableiOS = enableiOSSimulators ? isXcodeSetupCorrect : true let enableAndroid = enableAndroidEmulators ? isAndroidSetupCorrect : true - return enableiOS && enableAndroid + let enableHarmony = enableHarmonySimulators ? isHarmonySetupCorrect : true + return enableiOS && enableAndroid && enableHarmony } func checkXcode() { @@ -39,10 +42,18 @@ struct SetupView: View { } } + func checkDevEcoStudio() { + if !enableHarmonySimulators { + return + } + isHarmonySetupCorrect = (try? HarmonyDeviceDiscovery().checkSetup()) != nil + } + func checkSetup() { isLoading = true checkAndroidStudio() checkXcode() + checkDevEcoStudio() isLoading = false } @@ -85,6 +96,18 @@ struct SetupView: View { } .redacted(reason: isLoading ? .placeholder : []) } + + if enableHarmonySimulators { + SetupItemView( + imageName: "box", + title: "DevEco Studio", + subTitle: isHarmonySetupCorrect ? + "Everything is running correctly." : + "DevEco Studio emulator command was not found." + ) { + } + .redacted(reason: isLoading ? .placeholder : []) + } HStack { Spacer() Button("Re-check") { diff --git a/MiniSim/Views/Preferences.swift b/MiniSim/Views/Preferences.swift index f803090..9721403 100644 --- a/MiniSim/Views/Preferences.swift +++ b/MiniSim/Views/Preferences.swift @@ -11,6 +11,9 @@ import SwiftUI struct Preferences: View { @State var menuImageSelected: String = "iphone" @State private var preferedTerminal: Terminal + @AppStorage(UserDefaults.Keys.enableiOSSimulators, store: .standard) var enableiOSSimulators = true + @AppStorage(UserDefaults.Keys.enableAndroidEmulators, store: .standard) var enableAndroidEmulators = true + @AppStorage(UserDefaults.Keys.enableHarmonySimulators, store: .standard) var enableHarmonySimulators = true init() { let userPrefered = UserDefaults.standard.preferedTerminal @@ -56,6 +59,13 @@ struct Preferences: View { Text("Users can choose their preferred terminal from the above supported terminal list") .descriptionText() } + Settings.Section(title: "Simulator Sources:") { + Toggle("iOS Simulators", isOn: $enableiOSSimulators) + Toggle("Android Emulators", isOn: $enableAndroidEmulators) + Toggle("HarmonyOS Simulators", isOn: $enableHarmonySimulators) + Text("HarmonyOS simulators require DevEco Studio to be installed.") + .descriptionText() + } Settings.Section(title: "Hotkey:") { KeyboardShortcuts.Recorder("", name: .toggleMiniSim) Text("Global shortcut to open the application \nDefault: ⌥⇧E") diff --git a/MiniSimTests/DeviceParserTests.swift b/MiniSimTests/DeviceParserTests.swift index 5d2bc64..cc3b9d3 100644 --- a/MiniSimTests/DeviceParserTests.swift +++ b/MiniSimTests/DeviceParserTests.swift @@ -59,6 +59,50 @@ class DeviceParserTests: XCTestCase { let androidParser = DeviceParserFactory().getParser(.androidEmulator) XCTAssertTrue(androidParser is AndroidEmulatorParser) + + let harmonyParser = DeviceParserFactory().getParser(.harmonySimulator) + XCTAssertTrue(harmonyParser is HarmonySimulatorParser) + } + + func testHarmonyLaunchArgumentsUseColdBootWithoutSavingSnapshot() { + XCTAssertEqual( + HarmonyDeviceTool.launchArguments(for: "Pura X Max"), + ["-start", "Pura X Max", "-bootmode", "coldboot_no_save"] + ) + } + + func testHarmonyDirectLaunchBundleContainsPrivacyUsageDescriptions() throws { + let fileManager = FileManager.default + let temporaryDirectory = fileManager.temporaryDirectory + .appendingPathComponent("MiniSim-HarmonyBundle-\(UUID().uuidString)", isDirectory: true) + let sourceDirectory = temporaryDirectory.appendingPathComponent("emulator", isDirectory: true) + let sourceExecutable = sourceDirectory.appendingPathComponent("Emulator") + defer { try? fileManager.removeItem(at: temporaryDirectory) } + + try fileManager.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + XCTAssertTrue(fileManager.createFile(atPath: sourceExecutable.path, contents: Data())) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: sourceExecutable.path) + + let launchPath = try HarmonyDeviceTool.getLaunchEmulatorPath( + sourcePath: sourceExecutable.path, + fileManager: fileManager, + homeDirectory: temporaryDirectory.path + ) + let infoURL = URL(fileURLWithPath: launchPath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Info.plist") + let info = try XCTUnwrap( + PropertyListSerialization.propertyList( + from: Data(contentsOf: infoURL), + options: [], + format: nil + ) as? [String: String] + ) + + XCTAssertEqual(info["CFBundlePackageType"], "APPL") + XCTAssertFalse(info["NSCameraUsageDescription"]?.isEmpty ?? true) + XCTAssertFalse(info["NSMicrophoneUsageDescription"]?.isEmpty ?? true) } func testIOSSimulatorParser() throws { @@ -239,6 +283,40 @@ class DeviceParserTests: XCTestCase { XCTAssertEqual(devices[0].type, .physical) } + func testHarmonySimulatorParser() throws { + let parser = HarmonySimulatorParser() + let input = """ + [ + { + "name": "Pura X Max", + "isRunning": "true", + "os.osVersion": "HarmonyOS 6.1.0(23)", + "uuid": "b3040788-893d-473c-b983-8f960fc512ec" + }, + { + "name": "Harmony Tablet", + "isRunning": false, + "os.softwareVersion": "6.1.0.115", + "uuid": "tablet-uuid" + } + ] + """ + + let devices = parser.parse(input) + + XCTAssertEqual(devices.count, 2) + let phone = try XCTUnwrap(devices.first { $0.name == "Pura X Max" }) + XCTAssertTrue(phone.booted) + XCTAssertEqual(phone.version, "HarmonyOS 6.1.0(23)") + XCTAssertEqual(phone.identifier, "b3040788-893d-473c-b983-8f960fc512ec") + XCTAssertEqual(phone.platform, .harmony) + XCTAssertEqual(phone.type, .virtual) + + let tablet = try XCTUnwrap(devices.first { $0.name == "Harmony Tablet" }) + XCTAssertFalse(tablet.booted) + XCTAssertEqual(tablet.version, "6.1.0.115") + } + func testIOSPhysicalDeviceParser() { let parser = IOSPhysicalDeviceParser() let emptyInput = """ diff --git a/README.md b/README.md index 7149a37..48afd91 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## About -MiniSim is a small utility menu bar app for launching Android 🤖 and iOS  emulators (and more!). +MiniSim is a small utility menu bar app for launching Android 🤖, iOS , and HarmonyOS emulators (and more!). Written in Swift and AppKit. @@ -38,6 +38,9 @@ You can use Minisim together with a [Raycast](https://www.raycast.com) extension - Toggle a11y on selected emulator - Copy device name - Copy device ADB id +- Launch HarmonyOS simulators + - Start and delete DevEco Studio emulators + - Copy simulator ID - Focus devices using accessibility API - Set default launch flags - Indicate running devices @@ -51,7 +54,7 @@ https://user-images.githubusercontent.com/52801365/224473566-a6248f20-8fc9-4b8e- ## Usage > **important** -> This utility uses `xcrun` and `sdk/emulator` to fetch available devices on your machine. +> This utility uses `xcrun`, Android `sdk/emulator`, and DevEco Studio's `Emulator` command to fetch available devices on your machine. > It might not work if you don't have a proper XCode and Android Studio setup. There is a global shortcut for invoking the menu: ⌥ + ⇧ + e. diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh new file mode 100755 index 0000000..b2b4066 --- /dev/null +++ b/scripts/build-dmg.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_FILE="$PROJECT_ROOT/MiniSim.xcodeproj" +DIST_DIR="$PROJECT_ROOT/dist" +DERIVED_DATA_DIR="$DIST_DIR/DerivedData" +STAGING_DIR="$DIST_DIR/dmg-staging" +APP_PATH="$DERIVED_DATA_DIR/Build/Products/Release/MiniSim.app" +DMG_PATH="$DIST_DIR/MiniSim.dmg" + +# Reuse an existing Xcode package checkout when available. This avoids an +# unnecessary network clone on machines that have already built MiniSim. +PACKAGE_SOURCE_DIR="${PACKAGE_SOURCE_DIR:-}" +if [[ -z "$PACKAGE_SOURCE_DIR" ]]; then + PACKAGE_SOURCE_DIR="$(find "$HOME/Library/Developer/Xcode/DerivedData" \ + -maxdepth 3 \ + -type d \ + -path '*/MiniSim-*/SourcePackages' \ + -print \ + -quit 2>/dev/null || true)" +fi + +if [[ ! -d "$PROJECT_FILE" ]]; then + echo "Project not found: $PROJECT_FILE" >&2 + exit 1 +fi + +echo "Building unsigned Release app for arm64 and x86_64..." +rm -rf "$DERIVED_DATA_DIR" "$STAGING_DIR" "$DMG_PATH" +mkdir -p "$DIST_DIR" + +XCODEBUILD_ARGS=( + -skipPackageUpdates \ + -project "$PROJECT_FILE" \ + -scheme MiniSim \ + -configuration Release \ + -derivedDataPath "$DERIVED_DATA_DIR" \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build +) + +if [[ -n "$PACKAGE_SOURCE_DIR" ]]; then + echo "Reusing Swift package cache: $PACKAGE_SOURCE_DIR" + XCODEBUILD_ARGS=( + -clonedSourcePackagesDirPath "$PACKAGE_SOURCE_DIR" + "${XCODEBUILD_ARGS[@]}" + ) +fi + +xcodebuild "${XCODEBUILD_ARGS[@]}" + +if [[ ! -d "$APP_PATH" ]]; then + echo "Build succeeded but MiniSim.app was not found: $APP_PATH" >&2 + exit 1 +fi + +echo "Preparing DMG contents..." +mkdir -p "$STAGING_DIR" +ditto "$APP_PATH" "$STAGING_DIR/MiniSim.app" +ln -s /Applications "$STAGING_DIR/Applications" + +echo "Creating $DMG_PATH..." +hdiutil create \ + -volname "MiniSim" \ + -srcfolder "$STAGING_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +echo +echo "DMG created successfully:" +echo "$DMG_PATH" +echo +file "$APP_PATH/Contents/MacOS/MiniSim" "$DMG_PATH"