├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .swiftlint.yml ├── CODE_OF_CONDUCT.md ├── ControlRoom.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Debug - ControlRoom.xcscheme │ └── Release - ControlRoom.xcscheme ├── ControlRoom ├── About UI │ ├── AboutView.swift │ └── Contributors.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128.png │ │ ├── icon_128@2x.png │ │ ├── icon_16.png │ │ ├── icon_16@2x.png │ │ ├── icon_256.png │ │ ├── icon_256@2x.png │ │ ├── icon_32.png │ │ ├── icon_32@2x.png │ │ ├── icon_512.png │ │ └── icon_512@2x.png │ └── Contents.json ├── Base.lproj │ └── Main.storyboard ├── ControlRoom.entitlements ├── ControlRoomApp.swift ├── Controllers │ ├── Application.swift │ ├── ApplicationType.swift │ ├── CaptureController.swift │ ├── ChromeRendering │ │ ├── ChromeRenderer.swift │ │ └── ChromeRendererTypes.swift │ ├── ColorHistoryController.swift │ ├── DeepLinksController.swift │ ├── KeyboardShortcuts.swift │ ├── LocalSearchController.swift │ ├── LocationsController.swift │ ├── Preferences.swift │ ├── PushNotification.swift │ ├── SimCtl+SubCommands.swift │ ├── SimCtl+Types.swift │ ├── SimCtl.swift │ ├── Simulator.swift │ ├── SimulatorsController.swift │ ├── Snapshot.swift │ ├── SnapshotCtl+Commands.swift │ ├── SnapshotCtl.swift │ ├── UIState.swift │ └── XcodeCommandLineToolsController.swift ├── Document Picker │ ├── DocumentPicker.swift │ ├── DocumentPickerConfig.swift │ └── UTType+Extension.swift ├── Extensions │ ├── AVAssetToGIF.swift │ ├── CLLocationCoordinate2D-Identifiable.swift │ └── KeyedEncodingContainer-NotEmpty.swift ├── Helpers │ ├── Binding-OnChange.swift │ ├── Capture.swift │ ├── Collection.swift │ ├── CommandLineExecuter.swift │ ├── ContextMenu.swift │ ├── DeepLink.swift │ ├── Defaults.swift │ ├── Double-Rounding.swift │ ├── FFMPEGConverter.swift │ ├── Flow.swift │ ├── LocalSearchResult.swift │ ├── Location.swift │ ├── NSColor-Conversions.swift │ ├── PickedColor.swift │ ├── Process.swift │ ├── TypeIdentifier.swift │ ├── URLFileAttribute.swift │ ├── XcodeColorSet.swift │ └── XcodeHelper.swift ├── Info.plist ├── Loading │ ├── LoadingFailedView.swift │ └── LoadingView.swift ├── Localizable.xcstrings ├── Main Window │ ├── CreateSimulatorActionSheet.swift │ ├── MainView.swift │ ├── MainWindowController.swift │ ├── SidebarView.swift │ ├── SimulatorAction.swift │ ├── SimulatorActionSheet.swift │ ├── SimulatorSidebarView.swift │ └── SplitLayoutView.swift ├── NSViewWrappers │ └── SearchField.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Settings UI │ ├── ColorPickerView.swift │ ├── NotificationsFormView.swift │ ├── PathToTerminalTextFieldView.swift │ ├── PickersFormView.swift │ ├── SettingsView.swift │ └── TogglesFormView.swift ├── Simulator UI │ ├── ControlScreens │ │ ├── AppView │ │ │ ├── AppIcon.swift │ │ │ ├── AppSummaryView.swift │ │ │ ├── AppView.swift │ │ │ ├── NotificationEditorView.strings │ │ │ └── NotificationEditorView.swift │ │ ├── ColorsView.swift │ │ ├── LocationVIew │ │ │ ├── LocalSearchRowView.swift │ │ │ └── LocationView.swift │ │ ├── OverridesView.swift │ │ ├── SnapshotAction.swift │ │ ├── SnapshotsView.swift │ │ ├── StatusBarView.swift │ │ └── SystemView │ │ │ ├── DeepLinkEditorView.swift │ │ │ └── SystemView.swift │ └── ControlView.swift └── ar.lproj │ └── Main.strings ├── ControlRoomTests ├── Controllers │ └── SimCtl+SubCommandsTests.swift └── Info.plist ├── LICENSE ├── README.md └── Working ├── logo.png ├── logo.psd └── logo.sketch /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | # Set default charset 10 | [*.{swift,plist,yml,md}] 11 | indent_style = space 12 | charset = utf-8 13 | 14 | # 4 space indentation 15 | [*.{swift,yml}] 16 | indent_size = 4 17 | 18 | [*.md] 19 | indent_size = 2 20 | 21 | [{Makefile,makefile}] 22 | indent_style = tab 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Xcode - Build and Test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | name: 'Build and test: Debug' 8 | runs-on: macos-13 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - name: Selected Xcode version 14 | run: | 15 | xcode-select -p 16 | - name: Build and test 17 | run: | 18 | xcodebuild clean build analyze test -project ControlRoom.xcodeproj -scheme 'Debug - ControlRoom' -destination 'platform=macOS' CONFIGURATION_BUILD_DIR=$(pwd)/build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO | xcpretty -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | .DS_Store 8 | 9 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 10 | *.xcscmblueprint 11 | *.xccheckout 12 | 13 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 14 | build/ 15 | DerivedData/ 16 | *.moved-aside 17 | *.pbxuser 18 | !default.pbxuser 19 | *.mode1v3 20 | !default.mode1v3 21 | *.mode2v3 22 | !default.mode2v3 23 | *.perspectivev3 24 | !default.perspectivev3 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | 29 | ## App packaging 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | # Package.pins 43 | # Package.resolved 44 | # *.xcodeproj 45 | # 46 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 47 | # hence it is not needed unless you have added a package configuration file to your project 48 | # .swiftpm 49 | 50 | .build/ 51 | 52 | # CocoaPods 53 | # 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # 58 | # Pods/ 59 | # 60 | # Add this line if you want to avoid checking in source code from the Xcode workspace 61 | # *.xcworkspace 62 | 63 | # Carthage 64 | # 65 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 66 | # Carthage/Checkouts 67 | 68 | Carthage/Build/ 69 | 70 | # Accio dependency management 71 | Dependencies/ 72 | .accio/ 73 | 74 | # fastlane 75 | # 76 | # It is recommended to not store the screenshots in the git repo. 77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 78 | # For more information about the recommended setup visit: 79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 80 | 81 | fastlane/report.xml 82 | fastlane/Preview.html 83 | fastlane/screenshots/**/*.png 84 | fastlane/test_output 85 | 86 | # Code Injection 87 | # 88 | # After new code Injection tools there's a generated folder /iOSInjectionProject 89 | # https://github.com/johnno1962/injectionforxcode 90 | 91 | iOSInjectionProject/ 92 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | type_name: 2 | allowed_symbols: "_" 3 | identifier_name: 4 | min_length: 2 5 | line_length: 6 | warning: 220 7 | error: 250 8 | identifier_name: 9 | allowed_symbols: "_" 10 | 11 | disabled_rules: 12 | - non_optional_string_data_conversion 13 | - optional_data_string_conversion 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at paul@hackingwithswift.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 44 | 45 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /ControlRoom.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ControlRoom.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ControlRoom.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "69c39523ac0471922b625cc56b8722155cfd2afd660bdd4f5ff67335b5b87714", 3 | "pins" : [ 4 | { 5 | "identity" : "keyboardshortcuts", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sindresorhus/KeyboardShortcuts", 8 | "state" : { 9 | "revision" : "045cf174010beb335fa1d2567d18c057b8787165", 10 | "version" : "2.3.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /ControlRoom.xcodeproj/xcshareddata/xcschemes/Debug - ControlRoom.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /ControlRoom.xcodeproj/xcshareddata/xcschemes/Release - ControlRoom.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 54 | 56 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /ControlRoom/About UI/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutView.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/19/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AboutView: View { 12 | var appName: String { 13 | (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? "Control Room" 14 | } 15 | 16 | var appVersion: String { 17 | (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "1.0" 18 | } 19 | 20 | var appBuild: String { 21 | (Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String) ?? "1.0" 22 | } 23 | 24 | var copyright: String { 25 | let copyright = Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright") as? String 26 | return copyright ?? "Copyright © 2023 Paul Hudson. All rights reserved." 27 | } 28 | 29 | let authors: [Author] 30 | 31 | var body: some View { 32 | VStack(spacing: 8) { 33 | Image(nsImage: NSImage(named: NSImage.applicationIconName)!) 34 | .resizable() 35 | .aspectRatio(1.0, contentMode: .fit) 36 | .frame(width: 64, height: 64) 37 | 38 | Text("Control Room") 39 | .fontWeight(.bold) 40 | 41 | Text("Version \(appVersion) (\(appBuild))") 42 | .font(.caption) 43 | 44 | if authors.isNotEmpty { 45 | Text("Built thanks to the contributions of:") 46 | .font(.caption) 47 | 48 | // contributors 49 | CollectionView(authors, horizontalSpacing: 0, horizontalAlignment: .center, verticalSpacing: 0) { author in 50 | Link("@\(author.login)", destination: author.htmlUrl) 51 | .padding(2) 52 | } 53 | .font(.caption) 54 | } 55 | 56 | Text(copyright) 57 | .font(.caption) 58 | } 59 | .padding(20) 60 | } 61 | } 62 | 63 | struct AboutView_Previews: PreviewProvider { 64 | static var previews: some View { 65 | AboutView(authors: []) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ControlRoom/About UI/Contributors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Contributors.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/19/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Author: Decodable, Identifiable { 12 | let login: String 13 | let htmlUrl: URL 14 | 15 | var id: String { login } 16 | } 17 | 18 | private struct Contributor: Decodable, Comparable { 19 | static func < (lhs: Contributor, rhs: Contributor) -> Bool { 20 | lhs.total < rhs.total 21 | } 22 | 23 | static func == (lhs: Contributor, rhs: Contributor) -> Bool { 24 | lhs.author.id == rhs.author.id 25 | } 26 | 27 | let total: Int 28 | let author: Author 29 | } 30 | 31 | extension Bundle { 32 | var authors: [Author] { 33 | guard let fileURL = url(forResource: "contributors", withExtension: "json") else { return [] } 34 | guard let rawJSON = try? Data(contentsOf: fileURL) else { return [] } 35 | 36 | let decoder = JSONDecoder() 37 | decoder.keyDecodingStrategy = .convertFromSnakeCase 38 | 39 | guard let contributors = try? decoder.decode([Contributor].self, from: rawJSON) else { return [] } 40 | return contributors.sorted().reversed().map(\.author) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_128.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_16.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_256.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_32.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_512.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/ControlRoom/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png -------------------------------------------------------------------------------- /ControlRoom/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ControlRoom/ControlRoom.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | com.apple.security.temporary-exception.apple-events 8 | 9 | com.apple.terminal 10 | com.apple.terminal.shell-script 11 | com.apple.terminal.settings 12 | com.apple.terminal.session 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ControlRoom/ControlRoomApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControlRoomApp.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import KeyboardShortcuts 10 | import SwiftUI 11 | 12 | @main 13 | struct ControlRoomApp: App { 14 | @AppStorage("CRWantsMenuBarIcon") private var wantsMenuBarIcon = true 15 | @AppStorage("CRApps_LastOpenURL") private var lastOpenURL = "" 16 | @AppStorage("CRApps_LastBundleID") private var lastBundleID = "" 17 | @AppStorage("CRLastSimulatorUDID") private var lastSimulatorUDID = "booted" 18 | @AppStorage("CRApps_PushPayload") private var pushPayload = """ 19 | { 20 | "aps": { 21 | "alert": { 22 | "body": "Hello, World!", 23 | "title": "From Control Room" 24 | } 25 | } 26 | } 27 | """ 28 | 29 | @StateObject var preferences: Preferences 30 | @StateObject var controller: SimulatorsController 31 | @StateObject var deepLinks = DeepLinksController() 32 | 33 | var body: some Scene { 34 | Window("Control Room", id: "main") { 35 | MainView(controller: controller) 36 | .environmentObject(preferences) 37 | .environmentObject(UIState.shared) 38 | .environmentObject(deepLinks) 39 | } 40 | .commands { 41 | CommandGroup(replacing: .appInfo) { 42 | Button("About Control Room") { 43 | let authors = Bundle.main.authors 44 | 45 | if authors.isNotEmpty { 46 | let content = NSViewController() 47 | content.title = "Control Room" 48 | let view = NSHostingView(rootView: AboutView(authors: authors)) 49 | view.frame.size = view.fittingSize 50 | content.view = view 51 | let panel = NSPanel(contentViewController: content) 52 | panel.styleMask = [.closable, .titled] 53 | panel.orderFront(nil) 54 | panel.makeKey() 55 | } else { 56 | NSApp.orderFrontStandardAboutPanel(nil) 57 | } 58 | } 59 | } 60 | } 61 | 62 | Settings { 63 | SettingsView() 64 | .environmentObject(preferences) 65 | } 66 | 67 | MenuBarExtra(isInserted: .constant(preferences.wantsMenuBarIcon)) { 68 | if deepLinks.links.isEmpty == false { 69 | Menu("Saved deep links") { 70 | ForEach(deepLinks.links) { link in 71 | Button(link.name) { 72 | open(link) 73 | } 74 | } 75 | } 76 | 77 | Divider() 78 | } 79 | 80 | Button("Resend last push notification", action: resendLastPushNotification) 81 | .keyboardShortcut("p", modifiers: [.control, .option, .command]) 82 | Button("Restart last selected app", action: restartLastSelectedApp) 83 | .keyboardShortcut("r", modifiers: [.control, .option, .command]) 84 | Button("Reopen last URL", action: reopenLastURL) 85 | .keyboardShortcut("u", modifiers: [.control, .option, .command]) 86 | } label: { 87 | Label("Control Room", systemImage: "gear") 88 | 89 | } 90 | } 91 | 92 | init() { 93 | let preferences = Preferences() 94 | _preferences = StateObject(wrappedValue: preferences) 95 | _controller = StateObject(wrappedValue: SimulatorsController(preferences: preferences)) 96 | } 97 | 98 | func resendLastPushNotification() { 99 | SimCtl.sendPushNotification(lastSimulatorUDID, appID: lastBundleID, jsonPayload: pushPayload) 100 | } 101 | 102 | func restartLastSelectedApp() { 103 | SimCtl.restart(lastSimulatorUDID, appID: lastBundleID) 104 | } 105 | 106 | func reopenLastURL() { 107 | SimCtl.openURL(lastSimulatorUDID, URL: lastOpenURL) 108 | } 109 | 110 | func open(_ link: DeepLink) { 111 | SimCtl.openURL(lastSimulatorUDID, URL: link.url.absoluteString) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/Application.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Application.swift 3 | // ControlRoom 4 | // 5 | // Created by Mario Iannotta on 14/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | struct Application: Hashable, Comparable { 13 | let url: URL? 14 | let type: ApplicationType? 15 | let displayName: String 16 | let bundleIdentifier: String 17 | let versionNumber: String 18 | let buildNumber: String 19 | let imageURLs: [URL]? 20 | let dataFolderURL: URL? 21 | let firstAppGroupFolderURL: URL? 22 | let bundleURL: URL? 23 | 24 | static let `default` = Application() 25 | 26 | static func < (lhs: Application, rhs: Application) -> Bool { 27 | lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending 28 | } 29 | 30 | private init() { 31 | url = nil 32 | type = nil 33 | displayName = "" 34 | bundleIdentifier = "" 35 | versionNumber = "" 36 | buildNumber = "" 37 | imageURLs = nil 38 | dataFolderURL = nil 39 | firstAppGroupFolderURL = nil 40 | bundleURL = nil 41 | } 42 | 43 | init?(application: SimCtl.Application) { 44 | guard let url = URL(string: application.bundlePath) else { return nil } 45 | 46 | self.url = url 47 | type = application.type 48 | displayName = application.displayName 49 | 50 | let plistURL = url.appendingPathComponent("Info.plist") 51 | let plistDictionary = NSDictionary(contentsOf: plistURL) 52 | bundleIdentifier = application.bundleIdentifier 53 | versionNumber = plistDictionary?["CFBundleShortVersionString"] as? String ?? "" 54 | buildNumber = plistDictionary?["CFBundleVersion"] as? String ?? "" 55 | 56 | imageURLs = Self.fetchIconName(plistDictionary: plistDictionary) 57 | .sorted(by: >) 58 | .compactMap { Bundle(url: url)?.urlForImageResource($0) } 59 | 60 | dataFolderURL = URL(string: application.dataFolderPath ?? "") 61 | firstAppGroupFolderURL = URL(string: application.appGroupsFolderPaths?.first?.value ?? "") 62 | bundleURL = URL(string: application.bundlePath) 63 | } 64 | 65 | var icon: NSImage? { 66 | guard let imageURLs else { return nil } 67 | 68 | for iconURL in imageURLs { 69 | if let iconImage = NSImage(contentsOf: iconURL) { 70 | return iconImage 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | private static func fetchIconName(plistDictionary: NSDictionary?) -> [String] { 78 | guard let plistDictionary else { return [] } 79 | 80 | var iconFilesNames = iconsList(plistDictionary: plistDictionary) 81 | 82 | if iconFilesNames.isEmpty { 83 | iconFilesNames = iconsList(plistDictionary: plistDictionary, platformIdentifier: "~ipad") 84 | 85 | // If empty, check for CFBundleIconFiles (since 3.2) 86 | if iconFilesNames.isEmpty, let iconFiles = plistDictionary["CFBundleIconFiles"] as? [String] { 87 | iconFilesNames = iconFiles 88 | } 89 | } 90 | 91 | if iconFilesNames.isNotEmpty { 92 | // Search some patterns for primary app icon 93 | for match in ["76", "60"] { 94 | let result = iconFilesNames.filter { $0.contains(match) } 95 | 96 | if result.isNotEmpty { 97 | return result 98 | } 99 | } 100 | 101 | return iconFilesNames 102 | } 103 | 104 | // Check for CFBundleIconFile (legacy, before 3.2) 105 | if let iconFileName = plistDictionary["CFBundleIconFile"] as? String { 106 | return [iconFileName] 107 | } 108 | 109 | return [] 110 | } 111 | 112 | private static func iconsList(plistDictionary: NSDictionary?, platformIdentifier: String = "") -> [String] { 113 | let scaleSuffixes: [String] = ["@2x", "@3x"] 114 | 115 | guard 116 | let plistDictionary = plistDictionary, 117 | let iconsDictionary = plistDictionary["CFBundleIcons\(platformIdentifier)"] as? NSDictionary, 118 | let primaryIconDictionary = iconsDictionary["CFBundlePrimaryIcon"] as? NSDictionary, 119 | let iconFilesNames = primaryIconDictionary["CFBundleIconFiles"] as? [String] 120 | else { 121 | return [] 122 | } 123 | 124 | var fullIconNames = [String]() 125 | 126 | iconFilesNames.forEach { iconFileName in 127 | scaleSuffixes.forEach { scaleSuffix in 128 | fullIconNames.append(iconFileName+scaleSuffix+platformIdentifier) 129 | } 130 | } 131 | 132 | return fullIconNames 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/ApplicationType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApplicationType.swift 3 | // ControlRoom 4 | // 5 | // Created by Mario on 15/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum ApplicationType: String, Decodable { 12 | case user = "User" 13 | case system = "System" 14 | } 15 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/CaptureController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureController.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 10/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Handles all screenshotting and video creation. 12 | class CaptureController: ObservableObject { 13 | /// The user's settings for capturing 14 | @AppStorage("captureSettings") var settings = CaptureSettings(imageFormat: .png, videoFormat: .h264, display: .internal, mask: .ignored, saveURL: .desktop) 15 | 16 | /// The currently active recording process, if it exists. We don't need to monitor this, just keep it alive. 17 | @Published var recordingProcess: Process? 18 | 19 | /// The name of the file we're writing to, used at first in a temporary directory then on the desktop. 20 | @Published var recordingFilename = "" 21 | 22 | /// The export format description to be shown while exporting 23 | @Published var exportDescription = "" 24 | 25 | /// Converting MP4 to GIF takes time, so this tracks the progress of the operation 26 | @Published var exportProgress: CGFloat = 1.0 27 | 28 | private var videoFormat = SimCtl.IO.VideoFormat.h264 29 | 30 | var imageFormatString: String { 31 | settings.imageFormat.rawValue.uppercased() 32 | } 33 | 34 | var videoFormatString: String { 35 | settings.videoFormat.name 36 | } 37 | 38 | @MainActor 39 | /// Takes a screenshot of the device's current screen and saves it to the desktop. 40 | func takeScreenshot(of simulator: Simulator, format: SimCtl.IO.ImageFormat? = nil) { 41 | // If the user asked for a specific format then use it, otherwise 42 | // use whatever is our default. 43 | let resolvedFormat = format ?? settings.imageFormat 44 | 45 | // The filename where we intend to save this image 46 | let filename = makeScreenshotFilename(format: resolvedFormat) 47 | 48 | SimCtl.saveScreenshot(simulator.id, to: filename.path(), type: resolvedFormat, display: settings.display, with: settings.mask) { result in 49 | 50 | if UserDefaults.standard.bool(forKey: "renderChrome") { 51 | if let image = NSImage(contentsOf: filename) { 52 | Task { @MainActor in 53 | if let renderer = try? ChromeRenderer(deviceName: simulator.name, screenshot: image) { 54 | let result = renderer.makeImage() 55 | 56 | if let tiff = result?.tiffRepresentation { 57 | let bitmap = NSBitmapImageRep(data: tiff) 58 | if let compressedBitmap = bitmap?.representation(using: resolvedFormat.nsFileType, properties: [:]) { 59 | try FileManager.default.removeItem(at: filename) 60 | try compressedBitmap.write(to: filename) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | /// Creates a filename for a screenshot that ought to be unique 71 | func makeScreenshotFilename(format: SimCtl.IO.ImageFormat) -> URL { 72 | let formatter = DateFormatter() 73 | formatter.dateFormat = "y-MM-dd-HH-mm-ss" 74 | 75 | let dateString = formatter.string(from: Date.now) 76 | 77 | return settings.saveURL.url.appending(path: "ControlRoom-\(dateString).\(format.rawValue)") 78 | } 79 | 80 | /// Starts recording video of the device, saving it to the desktop. 81 | func startRecordingVideo(of simulator: Simulator, format: SimCtl.IO.VideoFormat? = nil) { 82 | // Store the format we've been asked to record in, so we can export to GIF 83 | // correctly later on. 84 | videoFormat = format ?? settings.videoFormat 85 | 86 | recordingFilename = makeVideoFilename() 87 | 88 | let tempPath = FileManager.default.temporaryDirectory.appendingPathComponent(recordingFilename).path 89 | 90 | recordingProcess = SimCtl.startVideo(simulator.id, to: tempPath, type: .h264, display: settings.display, with: settings.mask) 91 | } 92 | 93 | func stopRecordingVideo() { 94 | recordingProcess?.interrupt() 95 | recordingProcess?.waitUntilExit() 96 | recordingProcess = nil 97 | 98 | let sourceURL = FileManager.default.temporaryDirectory.appendingPathComponent(recordingFilename) 99 | 100 | let savePath = settings.saveURL.url.appendingPathComponent(recordingFilename).path 101 | 102 | let format = videoFormat.name 103 | 104 | if format.hasPrefix("GIF") { 105 | exportGif(format, savePath, sourceURL) 106 | } else if format.contains("Compressed") { 107 | exportCompressedVideo(savePath, sourceURL) 108 | } else { 109 | try? FileManager.default.moveItem(atPath: sourceURL.path, toPath: savePath) 110 | } 111 | } 112 | 113 | /// Saves recorded video as a GIF-file 114 | private func exportGif(_ format: String, _ savePath: String, _ sourceURL: URL) { 115 | let size: CGFloat? 116 | 117 | if format.contains("Small") { 118 | size = 400 119 | } else if format.contains("Medium") { 120 | size = 800 121 | } else if format.contains("Large") { 122 | size = 1200 123 | } else { 124 | size = 1600 125 | } 126 | 127 | let gifExtension = savePath.replacingOccurrences(of: ".mp4", with: ".gif") 128 | 129 | exportDescription = "GIF" 130 | 131 | Task { 132 | let result = try await sourceURL.convertToGIF(maxSize: size) { [weak self] progress in 133 | self?.exportProgress = progress 134 | } 135 | 136 | switch result { 137 | case .success(let gifURL): 138 | try? FileManager.default.moveItem(atPath: gifURL.path, toPath: gifExtension) 139 | case .failure(let reason): 140 | print(reason.localizedDescription) 141 | } 142 | } 143 | } 144 | 145 | /// Compresses recorded video with `ffmpeg` before saving 146 | private func exportCompressedVideo(_ savePath: String, _ sourceURL: URL) { 147 | guard FFMPEGConverter.available else { 148 | try? FileManager.default.moveItem(atPath: sourceURL.path, toPath: savePath) 149 | print("The 'ffmpeg' isn't available.") 150 | return 151 | } 152 | 153 | let convertPath = sourceURL.path.appending("-compressed.mp4") 154 | exportDescription = "Compressed Video" 155 | exportProgress = 0.0 156 | FFMPEGConverter.convert(input: sourceURL.path, output: convertPath) { [weak self] result in 157 | self?.exportProgress = 1.0 158 | switch result { 159 | case .success: 160 | try? FileManager.default.moveItem(atPath: convertPath, toPath: savePath) 161 | case .failure(let reason): 162 | print(reason.localizedDescription) 163 | } 164 | } 165 | } 166 | 167 | /// Creates a filename for a video that ought to be unique 168 | func makeVideoFilename() -> String { 169 | let formatter = DateFormatter() 170 | formatter.dateFormat = "y-MM-dd-HH-mm-ss" 171 | 172 | let dateString = formatter.string(from: Date.now) 173 | 174 | return "ControlRoom-\(dateString).mp4" 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/ChromeRendering/ChromeRendererTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChromeRendererTypes.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 15/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | /// This file contains all the Decodable types required to work with Apple's property list and JSON 10 | /// files that handle simulator device and chrome data. 11 | 12 | import Foundation 13 | 14 | struct SimulatorDevice: Decodable { 15 | var chromeIdentifier: String 16 | var mainScreenScale: Double 17 | } 18 | 19 | struct SimulatorChrome: Decodable { 20 | var identifier: String 21 | var images: SimulatorImageSet 22 | var inputs: [SimulatorImageInput] 23 | } 24 | 25 | struct SimulatorImageSet: Decodable { 26 | var topLeft: String 27 | var top: String 28 | var topRight: String 29 | var right: String 30 | var bottomRight: String 31 | var bottom: String 32 | var bottomLeft: String 33 | var left: String 34 | var screen: String 35 | var sizing: SimulatorImageSetSizing 36 | var padding: SimulatorSize 37 | var devicePadding: SimulatorImagePadding 38 | } 39 | 40 | struct SimulatorImageSetSizing: Decodable { 41 | var leftWidth: Double 42 | var rightWidth: Double 43 | var topHeight: Double 44 | var bottomHeight: Double 45 | } 46 | 47 | struct SimulatorSize: Decodable { 48 | var width: Double 49 | var height: Double 50 | } 51 | 52 | // swiftlint:disable identifier_name 53 | struct SimulatorPoint: Decodable { 54 | var x: Double 55 | var y: Double 56 | } 57 | // swiftlint:enable identifier_name 58 | 59 | struct SimulatorImagePadding: Decodable { 60 | var top: Double 61 | var left: Double 62 | var bottom: Double 63 | var right: Double 64 | } 65 | 66 | struct SimulatorPath: Decodable { 67 | var insets: SimulatorImagePadding 68 | var cornerRadiusX: Double 69 | var cornerRadiusY: Double 70 | } 71 | 72 | struct SimulatorImageInput: Decodable { 73 | var image: String 74 | var onTop: Bool 75 | var anchor: String 76 | var align: String 77 | var offsets: SimulatorOffsets 78 | } 79 | 80 | struct SimulatorOffsets: Decodable { 81 | var normal: SimulatorPoint 82 | var rollover: SimulatorPoint 83 | } 84 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/ColorHistoryController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorHistoryController.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Loads, manages, and saves the user's collection of picked colors. 12 | class ColorHistoryController: ObservableObject { 13 | /// The list of colors the user has picked over time. 14 | @Published private(set) var colors: [PickedColor] 15 | 16 | /// The UserDefaults key where we save our picked colors. 17 | private let defaultsKey = "CRColorHistory" 18 | 19 | /// Attempts to load saved colors from UserDefaults, or creates an empty array otherwise. 20 | init() { 21 | if let data = UserDefaults.standard.data(forKey: defaultsKey) { 22 | if let decoded = try? JSONDecoder().decode([PickedColor].self, from: data) { 23 | colors = decoded 24 | return 25 | } 26 | } 27 | 28 | colors = [] 29 | } 30 | 31 | /// Writes the user's picked colors to UserDefaults. 32 | private func save() { 33 | if let encoded = try? JSONEncoder().encode(colors) { 34 | UserDefaults.standard.set(encoded, forKey: defaultsKey) 35 | } 36 | } 37 | 38 | /// Creates a new PickedColor instance from an NSColor, adds it to the start of the array 39 | /// so it appears immediately in the UI, then triggers a save. 40 | /// - Parameters: 41 | /// - color: The NSColor we want to create 42 | /// - Returns: A PickedColor instance if it could be created. 43 | func add(_ color: NSColor?) -> PickedColor? { 44 | guard let color else { return nil } 45 | guard let pickedColor = PickedColor(from: color) else { return nil } 46 | 47 | colors.insert(pickedColor, at: 0) 48 | save() 49 | 50 | return pickedColor 51 | } 52 | 53 | /// Deletes a picked color instance based on its ID. 54 | /// - Parameter itemID: The identifier of the color we want to delete. 55 | func delete(_ itemID: PickedColor.ID?) { 56 | guard let itemID else { return } 57 | 58 | colors.removeAll { color in 59 | color.id == itemID 60 | } 61 | 62 | save() 63 | } 64 | 65 | /// Returns a picked color instance based on its ID. 66 | /// - Parameter itemID: The identifier of the color we want to return. 67 | /// - Returns: The PickedColor instance with the request ID, if it could be found. 68 | func item(with itemID: PickedColor.ID?) -> PickedColor? { 69 | guard let itemID else { return nil } 70 | 71 | return colors.first { color in 72 | color.id == itemID 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/DeepLinksController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinksController.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Loads, manages, and saves the user's collection of deep links 12 | class DeepLinksController: ObservableObject { 13 | /// The list of links the user has created, sorted however they want. 14 | @Published private(set) var links: [DeepLink] 15 | 16 | /// The UserDefaults key where we save our links. 17 | private let defaultsKey = "CRDeepLinks" 18 | 19 | /// Attempts to load saved links from UserDefaults, or creates an empty array otherwise. 20 | init() { 21 | if let data = UserDefaults.standard.data(forKey: defaultsKey) { 22 | if let decoded = try? JSONDecoder().decode([DeepLink].self, from: data) { 23 | links = decoded 24 | return 25 | } 26 | } 27 | 28 | links = [] 29 | } 30 | 31 | /// Writes the user's deep links to UserDefaults. 32 | private func save() { 33 | if let encoded = try? JSONEncoder().encode(links) { 34 | UserDefaults.standard.set(encoded, forKey: defaultsKey) 35 | } 36 | } 37 | 38 | /// Creates a new DeepLink instance from a name and URL string. 39 | /// - Parameters: 40 | /// - name: The user's name for this link. 41 | /// - url: The stringified URL to load, already prefixed with a schema. 42 | func create(name: String, url: String) { 43 | if let verifiedURL = URL(string: url) { 44 | let link = DeepLink(id: UUID(), name: name, url: verifiedURL) 45 | links.append(link) 46 | save() 47 | } 48 | } 49 | 50 | /// Updates an existing DeepLink with the new name and URL. No changes are made if the `itemID` is `nil`, a matching 51 | /// DeepLink cannot be found or if the new stringified URL fails construction as a `URL`. 52 | /// - Parameters: 53 | /// - itemID: The identifier of the link that needs to be updated. 54 | /// - name: The updated name for this link. 55 | /// - url: The updated stringified URL for the deep link. 56 | func edit(_ itemID: DeepLink.ID?, name: String, url: String) { 57 | guard 58 | let itemID, 59 | let index = links.firstIndex(where: { $0.id == itemID }), 60 | let verifiedURL = URL(string: url) 61 | else { 62 | return 63 | } 64 | 65 | var link = links[index] 66 | link.name = name 67 | link.url = verifiedURL 68 | links[index] = link 69 | 70 | save() 71 | } 72 | 73 | /// Deletes a DeepLink instance based on its ID. 74 | /// - Parameter itemID: The identifier of the link we want to delete. 75 | func delete(_ itemID: DeepLink.ID?) { 76 | guard let itemID else { return } 77 | 78 | links.removeAll { link in 79 | link.id == itemID 80 | } 81 | 82 | save() 83 | } 84 | 85 | /// Sorts the user's deep links using name or URL, then saves that order so it takes 86 | /// effect everywhere deep links are shown. 87 | /// - Parameter comparator: The sort order to use. 88 | func sort(using comparator: [KeyPathComparator]) { 89 | links.sort(using: comparator) 90 | save() 91 | } 92 | 93 | /// Finds the first deep link matching the desired DeepLink.ID 94 | /// - Parameter itemID: The identifier to search for. 95 | /// - Returns: The first matching DeepLink if one is found. Returns `nil` if no matching link is found or if 96 | /// `itemID` parameter is `nil`. 97 | func link(_ itemID: DeepLink.ID?) -> DeepLink? { 98 | guard let itemID else { return nil } 99 | 100 | return links.first(where: { $0.id == itemID }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/KeyboardShortcuts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardShortcuts.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 28/01/2021. 6 | // Copyright © 2021 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import KeyboardShortcuts 10 | 11 | extension KeyboardShortcuts.Name { 12 | static let resendLastPushNotification = Self("resendLastPushNotification", default: .init(.p, modifiers: [.control, .option, .command])) 13 | 14 | static let restartLastSelectedApp = Self("restartLastSelectedApp", default: .init(.r, modifiers: [.control, .option, .command])) 15 | 16 | static let reopenLastURL = Self("reopenLastURL", default: .init(.u, modifiers: [.control, .option, .command])) 17 | } 18 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/LocalSearchController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalSearchController.swift 3 | // ControlRoom 4 | // 5 | // Created by John McEvoy on 29/11/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import MapKit 11 | 12 | @MainActor 13 | class LocalSearchController: NSObject, ObservableObject { 14 | /// Prevents duplicate queries from being made 15 | private var lastQuery: String = "" 16 | 17 | /// Completion handler is called by the `MKLocalSearchCompleter` success callback 18 | private var callback: (([LocalSearchResult]) -> Void)? 19 | 20 | /// the MKLocalSearchCompleter used to make local search requests 21 | private lazy var localSearchCompleter: MKLocalSearchCompleter = { 22 | let completer = MKLocalSearchCompleter() 23 | completer.resultTypes = [.address, .pointOfInterest] 24 | completer.delegate = self 25 | return completer 26 | }() 27 | 28 | /** 29 | Finds places and POIs using a query string and a geographical point to focus on. 30 | 31 | - Parameter for: The partial (autocomplete) query to search for. 32 | - Parameter around: Provides a hint for `MKLocalSearchCompleter` to search around a geographical point. 33 | - Parameter completion: Called if valid search results are found. 34 | 35 | - Returns: If a location is found immediately (a coordinate was pasted in, for example), returns a `Location`. 36 | */ 37 | func search( 38 | for query: String, 39 | around location: Location, 40 | completion: @escaping ([LocalSearchResult]) -> Void 41 | ) -> Location? { 42 | guard query.isNotEmpty, query != lastQuery else { return nil } 43 | callback = completion 44 | lastQuery = query 45 | 46 | if let location = parseCoordinates(query) { 47 | return location 48 | } 49 | 50 | localSearchCompleter.queryFragment = query 51 | localSearchCompleter.region = MKCoordinateRegion( 52 | center: location.center, 53 | latitudinalMeters: CLLocationDistance(20000), 54 | longitudinalMeters: CLLocationDistance(20000) 55 | ) 56 | 57 | return nil 58 | } 59 | 60 | /** 61 | Converts an incomplete `LocalSearchResult` to a `Location` with coordinates and map bounds. 62 | 63 | - Parameter result: The `LocalSearchResult` to convert. 64 | - Parameter completion: Called if a valid `Location` is created. 65 | */ 66 | func select(_ result: LocalSearchResult, completion: @escaping (Location) -> Void) { 67 | guard let completer = result.completer else { return } 68 | 69 | Task { 70 | do { 71 | let request = MKLocalSearch.Request(completion: completer) 72 | let response = try await MKLocalSearch(request: request).start() 73 | guard let mapItem = response.mapItems.first else { return } 74 | let location = Location( 75 | id: result.id, 76 | name: result.title, 77 | latitude: mapItem.placemark.coordinate.latitude, 78 | longitude: mapItem.placemark.coordinate.longitude, 79 | latitudeDelta: response.boundingRegion.span.latitudeDelta, 80 | longitudeDelta: response.boundingRegion.span.longitudeDelta) 81 | completion(location) 82 | } catch { 83 | print("\(error)") 84 | } 85 | } 86 | } 87 | 88 | /// Uses a regex to detect if a string is a lat/long coordinate (e.g. `'37.33467, -122.00898'`) 89 | private func parseCoordinates(_ coordinateString: String) -> Location? { 90 | do { 91 | let regexSearch = try Regex("^-?(?:[1-8]?\\d(?:\\.\\d+)?|90(?:\\.0+)?),\\s*-?(?:180(?:\\.0+)?|1[0-7]\\d(?:\\.\\d+)?|\\d{1,2}(?:\\.\\d+)?)$") 92 | 93 | guard coordinateString.ranges(of: regexSearch).isNotEmpty else { 94 | return nil 95 | } 96 | 97 | let components = coordinateString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } 98 | 99 | guard let latitude = Double(components[0]), let longitude = Double(components[1]) else { 100 | return nil 101 | } 102 | 103 | return Location( 104 | id: UUID(), 105 | name: "Map coordinate", 106 | latitude: latitude, 107 | longitude: longitude) 108 | 109 | } catch { 110 | return nil 111 | } 112 | } 113 | } 114 | 115 | /// Adds `MKLocalSearchCompleterDelegate` conformance so the controller can use the delegate's callback methods 116 | extension LocalSearchController: MKLocalSearchCompleterDelegate { 117 | /// Called if `MKLocalSearchCompleter` return valid results from a query string 118 | func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { 119 | guard let callback else { return } 120 | let results = completer.results.map { 121 | LocalSearchResult( result: $0 ) 122 | } 123 | callback(results) 124 | } 125 | 126 | /// Called if `MKLocalSearchCompleter` encounters an error 127 | func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { 128 | print(error) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/LocationsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationsController.swift 3 | // ControlRoom 4 | // 5 | // Created by Alexander Chekel on 17.11.2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Loads, manages, and saves the user's collection of saved locations. 12 | class LocationsController: ObservableObject { 13 | /// The list of saved locations the user has created. 14 | @Published private(set) var locations: [Location] 15 | 16 | /// The UserDefaults key where we save locations. 17 | private let defaultsKey = "CRSavedLocations" 18 | 19 | /// Attempts to load saved locations from UserDefaults, or creates an empty array otherwise. 20 | init() { 21 | if let data = UserDefaults.standard.data(forKey: defaultsKey) { 22 | if let decoded = try? JSONDecoder().decode([Location].self, from: data) { 23 | locations = decoded 24 | return 25 | } 26 | } 27 | 28 | locations = [] 29 | } 30 | 31 | /// Creates a new Location instance from name, latitude, and longitude. 32 | /// - Parameters: 33 | /// - name: The user's name for this location. 34 | /// - latitude: Latitude. 35 | /// - longitude: Longitude. 36 | func create(name: String, latitude: Double, longitude: Double) { 37 | let location = Location(id: UUID(), name: name, latitude: latitude, longitude: longitude) 38 | locations.append(location) 39 | save() 40 | } 41 | 42 | /// Deletes a Location instance based on its ID. 43 | /// - Parameter itemID: The identifier of the location we want to delete. 44 | func delete(_ itemID: Location.ID?) { 45 | guard let itemID else { return } 46 | 47 | locations.removeAll { location in 48 | location.id == itemID 49 | } 50 | 51 | save() 52 | } 53 | 54 | /// Returns a Location instance based on its ID. 55 | /// - Parameter itemID: The identifier of the location we want to return. 56 | /// - Returns: The Location instance with the request ID, if it could be found. 57 | func item(with itemID: Location.ID?) -> Location? { 58 | guard let itemID else { return nil } 59 | 60 | return locations.first { location in 61 | location.id == itemID 62 | } 63 | } 64 | 65 | /// Writes the user's saved locations to UserDefaults. 66 | private func save() { 67 | if let encoded = try? JSONEncoder().encode(locations) { 68 | UserDefaults.standard.set(encoded, forKey: defaultsKey) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/16/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import KeyboardShortcuts 11 | import SwiftUI 12 | 13 | final class Preferences: ObservableObject { 14 | /// For parts of the app that want to observe a particular value directly, 15 | /// they need a way to be notified AFTER the value has changed. 16 | let objectDidChange = PassthroughSubject() 17 | 18 | @AppStorage("CRWantsMenuBarIcon") var wantsMenuBarIcon = true 19 | @AppStorage("CRWantsFloatingWindow") var wantsFloatingWindow = false 20 | 21 | @AppStorage("CRSidebar_ShowDefaultSimulator") var showDefaultSimulator = true 22 | @AppStorage("CRSidebar_ShowBootedDevicesFirst") var showBootedDevicesFirst = true 23 | @AppStorage("CRSidebar_ShowOnlyActiveDevices") var shouldShowOnlyActiveDevices = false 24 | 25 | @AppStorage("CRTerminalAppPath") var terminalAppPath = "/System/Applications/Utilities/Terminal.app" 26 | } 27 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/SimCtl+Types.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimCtl+Types.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/13/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension SimCtl { 12 | enum Platform { 13 | case iOS 14 | case tvOS 15 | case watchOS 16 | case visionOS 17 | } 18 | 19 | enum DeviceFamily: CaseIterable { 20 | case iPhone 21 | case iPad 22 | // swiftlint:disable:next identifier_name 23 | case tv 24 | case watch 25 | case visionPro 26 | 27 | var displayName: String { 28 | switch self { 29 | case .iPhone: return "iPhone" 30 | case .iPad: return "iPad" 31 | case .watch: return "Apple Watch" 32 | case .tv: return "Apple TV" 33 | case .visionPro: return "Apple Vision Pro" 34 | } 35 | } 36 | 37 | var snapshotUnavailableIcon: String { 38 | switch self { 39 | case .iPad, .iPhone: return "iphone.slash" 40 | case .watch: return "applewatch.slash" 41 | case .tv: return "tv.slash" 42 | case .visionPro: return "visionpro.slash" 43 | } 44 | } 45 | } 46 | 47 | struct DeviceTypeList: Decodable { 48 | let devicetypes: [DeviceType] 49 | } 50 | 51 | struct DeviceType: Decodable, Hashable, Identifiable { 52 | let bundlePath: String 53 | let name: String 54 | let identifier: String 55 | var id: String { identifier } 56 | 57 | var modelTypeIdentifier: TypeIdentifier? { 58 | guard let bundle = Bundle(path: bundlePath) else { return nil } 59 | guard let plist = bundle.url(forResource: "profile", withExtension: "plist") else { return nil } 60 | guard let contents = NSDictionary(contentsOf: plist) else { return nil } 61 | guard let modelIdentifier = contents.object(forKey: "modelIdentifier") as? String else { return nil } 62 | 63 | return TypeIdentifier(modelIdentifier: modelIdentifier) 64 | } 65 | 66 | var family: DeviceFamily { 67 | let type = modelTypeIdentifier ?? .defaultiPhone 68 | if type.conformsTo(.tv) { return .tv } 69 | if type.conformsTo(.watch) { return .watch } 70 | if type.conformsTo(.pad) { return .iPad } 71 | if type.conformsTo(.vision) { return .visionPro } 72 | return .iPhone 73 | } 74 | } 75 | 76 | struct DeviceList: Decodable, Equatable { 77 | let devices: [String: [Device]] 78 | } 79 | 80 | struct Device: Decodable, Equatable { 81 | let state: String? 82 | let isAvailable: Bool 83 | let name: String 84 | let udid: String 85 | let deviceTypeIdentifier: String? 86 | let dataPath: String? 87 | } 88 | 89 | struct RuntimeList: Decodable { 90 | let runtimes: [Runtime] 91 | } 92 | 93 | struct Runtime: Decodable, Hashable, Identifiable { 94 | static let unknown = Runtime(buildversion: "", identifier: "Unknown", version: "0.0.0", isAvailable: false, name: "Default OS") 95 | static let runtimeRegex = try? NSRegularExpression(pattern: #"^com\.apple\.CoreSimulator\.SimRuntime\.([a-z]+)-([0-9-]+)$"#, options: .caseInsensitive) 96 | 97 | let buildversion: String 98 | let identifier: String 99 | let version: String 100 | let isAvailable: Bool 101 | let name: String 102 | 103 | var id: String { identifier } 104 | 105 | var supportedFamilies: Set { 106 | if name.hasPrefix("iOS") { 107 | return [.iPhone, .iPad] 108 | } else if name.hasPrefix("watchOS") { 109 | return [.watch] 110 | } else if name.hasPrefix("tvOS") { 111 | return [.tv] 112 | } else if name.hasPrefix("visionOS") { 113 | return [.visionPro] 114 | } else { 115 | return [] 116 | } 117 | } 118 | 119 | /// The user-visible description of the runtime. 120 | var description: String { 121 | if buildversion.isNotEmpty { 122 | return "\(name) (\(buildversion))" 123 | } else { 124 | return "\(name)" 125 | } 126 | } 127 | 128 | /// Creates a Runtime when we know all its data. 129 | init(buildversion: String, identifier: String, version: String, isAvailable: Bool, name: String) { 130 | self.buildversion = buildversion 131 | self.identifier = identifier 132 | self.version = version 133 | self.isAvailable = isAvailable 134 | self.name = name 135 | } 136 | 137 | /// Creates a Runtime when we know only its identifier; we try to extrapolate properties from that string. 138 | init?(runtimeIdentifier: String) { 139 | guard let match = Runtime.runtimeRegex?.firstMatch(in: runtimeIdentifier, options: [.anchored], range: NSRange(location: 0, length: runtimeIdentifier.utf16.count)) else { 140 | return nil 141 | } 142 | 143 | let nsIdentifier = runtimeIdentifier as NSString 144 | let osName = nsIdentifier.substring(with: match.range(at: 1)) 145 | let version = nsIdentifier.substring(with: match.range(at: 2)).replacingOccurrences(of: "_", with: ".") 146 | 147 | self.buildversion = "" 148 | self.identifier = runtimeIdentifier 149 | self.version = version 150 | self.name = "\(osName) \(version)" 151 | self.isAvailable = false 152 | } 153 | } 154 | 155 | typealias ApplicationsList = [String: SimCtl.Application] 156 | 157 | struct Application: Decodable, Equatable { 158 | let type: ApplicationType 159 | let bundleIdentifier: String 160 | let displayName: String 161 | let bundlePath: String 162 | let dataFolderPath: String? 163 | let appGroupsFolderPaths: [String: String]? 164 | } 165 | } 166 | 167 | extension SimCtl.Application { 168 | 169 | private enum CodingKeys: String, CodingKey { 170 | case type = "ApplicationType" 171 | case bundleIdentifier = "CFBundleIdentifier" 172 | case displayName = "CFBundleDisplayName" 173 | case bundlePath = "Bundle" 174 | case dataFolderPath = "DataContainer" 175 | case appGroupsFolderPaths = "GroupContainers" 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/Snapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Snapshot.swift 3 | // ControlRoom 4 | // 5 | // Created by Marcel Mendes on 12/12/24. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | import Foundation 9 | 10 | struct Snapshot: Equatable, Hashable, Identifiable { 11 | let id: String 12 | let creationDate: Date 13 | let size: Int 14 | 15 | static func == (lhs: Snapshot, rhs: Snapshot) -> Bool { 16 | lhs.id == rhs.id 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/SnapshotCtl+Commands.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Command.swift 3 | // ControlRoom 4 | // 5 | // Created by Marcel Mendes on 12/12/24. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension SnapshotCtl { 12 | 13 | struct Command: CommandLineCommand { 14 | static let snapshotsFolder: String = ".snapshots" 15 | 16 | var command: String? 17 | let arguments: [String] 18 | let environmentOverrides: [String: String]? 19 | 20 | private init(_ command: String, arguments: [String], environmentOverrides: [String: String]? = nil) { 21 | self.command = command 22 | self.arguments = arguments 23 | self.environmentOverrides = environmentOverrides 24 | } 25 | 26 | static func createSnapshotTree(deviceId: String, snapshotName: String) -> Command { 27 | Command("/bin/mkdir", arguments: ["-p", "\(devicesPath)/\(snapshotsFolder)/\(deviceId)/\(snapshotName)"]) 28 | } 29 | 30 | /// Open app 31 | static func open(app: String) -> Command { 32 | Command("/usr/bin/open", arguments: ["-a", app]) 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/SnapshotCtl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapshotCtl.swift 3 | // ControlRoom 4 | // 5 | // Created by Marcel Mendes on 12/12/24. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | enum SnapshotCtl: CommandLineCommandExecuter { 13 | typealias Error = CommandLineError 14 | 15 | static var launchPath = "" 16 | static var snapshotsPath = "" 17 | static var devicesPath = "" { 18 | didSet { 19 | snapshotsPath = devicesPath + "/.snapshots" 20 | } 21 | } 22 | 23 | static func configureDevicesPath(dataPath: String?) { 24 | guard let dataPath else { 25 | print("Missing dataPath. Snapshots won't be created.") 26 | return 27 | } 28 | 29 | var folders: [String] = [] 30 | 31 | dataPath.split(separator: "/").forEach { 32 | folders.append("\($0)") 33 | } 34 | 35 | if let devicesIndex: Int = folders.firstIndex(of: "Devices") { 36 | devicesPath = "/" + folders.prefix(devicesIndex + 1).joined(separator: "/") 37 | } 38 | } 39 | 40 | static func getSnapshots(deviceId: String) -> [Snapshot] { 41 | let snapshotsPath: String = devicesPath + "/.snapshots/" + deviceId 42 | var snapshotIDs: [String] = [] 43 | var snapshots: [Snapshot] = [] 44 | 45 | do { 46 | snapshotIDs = try FileManager.default.contentsOfDirectory(atPath: snapshotsPath) 47 | } catch { } 48 | 49 | snapshotIDs.forEach { snapshotID in 50 | guard !snapshotID.hasPrefix(".") else { return } 51 | 52 | let snapshotPath: String = snapshotsPath + "/" + snapshotID 53 | let snapshotAttributes = getSnapshotAttributes(snapshotPath) 54 | 55 | guard let creationDate = snapshotAttributes.creationDate, 56 | let snapshotFolderSize = snapshotAttributes.folderSize else { return } 57 | let snapshot: Snapshot = .init(id: snapshotID, creationDate: creationDate, size: snapshotFolderSize) 58 | snapshots.append(snapshot) 59 | } 60 | 61 | return snapshots 62 | } 63 | 64 | static func createSnapshot(deviceId: String, snapshotName: String) { 65 | SimCtl.shutdown(deviceId) { _ in 66 | execute(.createSnapshotTree(deviceId: deviceId, snapshotName: snapshotName)) { _ in 67 | try? FileManager.default.copyItem(atPath: devicesPath + "/" + deviceId, toPath: snapshotsPath + "/" + deviceId + "/" + snapshotName + "/" + deviceId) 68 | } 69 | } 70 | } 71 | 72 | static func renameSnapshot(deviceId: String, snapshotName: String, newSnapshotName: String) { 73 | let snapshotPath: String = snapshotsPath + "/" + deviceId 74 | try? FileManager.default.moveItem(atPath: snapshotPath + "/" + snapshotName, toPath: snapshotPath + "/" + newSnapshotName) 75 | } 76 | 77 | static func deleteSnapshot(deviceId: String, snapshotName: String) { 78 | let snapshotPath: String = snapshotsPath + "/" + deviceId 79 | try? FileManager.default.removeItem(atPath: snapshotPath + "/" + snapshotName) 80 | } 81 | 82 | static func deleteAllSnapshots(deviceId: String) { 83 | let snapshotPath: String = snapshotsPath + "/" + deviceId 84 | try? FileManager.default.removeItem(atPath: snapshotPath) 85 | } 86 | 87 | static func restoreSnapshot(deviceId: String, snapshotName: String) { 88 | let snapshotPath: String = snapshotsPath + "/" + deviceId 89 | 90 | SimCtl.shutdown(deviceId) { _ in 91 | try? FileManager.default.removeItem(atPath: devicesPath + "/" + deviceId) 92 | try? FileManager.default.copyItem(atPath: snapshotPath + "/" + snapshotName + "/" + deviceId, toPath: devicesPath + "/" + deviceId) 93 | } 94 | } 95 | 96 | static func startSimulatorApp(completion: @escaping (() -> Void)) { 97 | execute(.open(app: "Simulator.app")) { _ in 98 | return completion() 99 | } 100 | } 101 | 102 | private static func getSnapshotAttributes(_ snapshotPath: String) -> URLFileAttribute { 103 | let snapshotURL: URL = URL(fileURLWithPath: snapshotPath) 104 | let snapshotAttributes = URLFileAttribute(url: snapshotURL) 105 | return snapshotAttributes 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/UIState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIState.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/16/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Combine 10 | 11 | class UIState: ObservableObject { 12 | enum Sheet: Int, Identifiable { 13 | case preferences 14 | case createSimulator 15 | case deepLinkEditor 16 | case notificationEditor 17 | case confirmDeleteSelected 18 | 19 | var id: Int { rawValue } 20 | } 21 | 22 | enum Alert: Int, Identifiable { 23 | case confirmDeleteUnavailable 24 | 25 | var id: Int { rawValue } 26 | } 27 | 28 | static let shared = UIState() 29 | @Published var currentSheet: Sheet? 30 | @Published var currentAlert: Alert? 31 | 32 | private init() { } 33 | } 34 | -------------------------------------------------------------------------------- /ControlRoom/Controllers/XcodeCommandLineToolsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XcodeCommandLineToolsController.swift 3 | // ControlRoom 4 | // 5 | // Created by Mario Iannotta on 30/03/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | struct XcodeCommandLineToolsController { 13 | static func selectedCommandLineTool() -> AnyPublisher { 14 | Publishers.CombineLatest(SystemProfiler.listDeveloperTools(), XcodeSelect.printPath()) 15 | .replaceError(with: ([], "")) 16 | .map { devTools, xcodeSelectResult -> DeveloperTool in 17 | for devTool in devTools { 18 | if xcodeSelectResult.contains(devTool.path), devTool.version >= "11.4" { 19 | return devTool 20 | } 21 | } 22 | return .empty 23 | } 24 | .eraseToAnyPublisher() 25 | } 26 | 27 | } 28 | 29 | private enum XcodeSelect: CommandLineCommandExecuter { 30 | typealias Error = CommandLineError 31 | 32 | static var launchPath = "/usr/bin/xcode-select" 33 | 34 | static func printPath() -> AnyPublisher { 35 | XcodeSelect.executeSubject(.printPath()) 36 | .compactMap { String(data: $0, encoding: .utf8) } 37 | .eraseToAnyPublisher() 38 | } 39 | } 40 | 41 | private extension XcodeSelect { 42 | struct Command: CommandLineCommand { 43 | let arguments: [String] 44 | var environmentOverrides: [String: String]? { nil } 45 | 46 | private init(_ subcommand: String, arguments: [String]) { 47 | self.arguments = [subcommand] + arguments 48 | } 49 | 50 | /// Print the path of the active developer directory. 51 | static func printPath() -> Command { 52 | Command("-p", arguments: []) 53 | } 54 | } 55 | } 56 | 57 | private enum SystemProfiler: CommandLineCommandExecuter { 58 | typealias Error = CommandLineError 59 | 60 | static var launchPath = "/usr/sbin/system_profiler" 61 | 62 | static func listDeveloperTools() -> AnyPublisher<[DeveloperTool], SystemProfiler.Error> { 63 | let publisher: AnyPublisher = SystemProfiler.executeJSON(.listDeveloperTools()) 64 | return publisher 65 | .map(\.list) 66 | .eraseToAnyPublisher() 67 | } 68 | } 69 | 70 | private extension SystemProfiler { 71 | struct Command: CommandLineCommand { 72 | let arguments: [String] 73 | var environmentOverrides: [String: String]? { nil } 74 | 75 | private init(_ subcommand: String, arguments: [String]) { 76 | self.arguments = [subcommand] + arguments 77 | } 78 | 79 | /// List the available developer tools. 80 | static func listDeveloperTools() -> Command { 81 | Command("SPDeveloperToolsDataType", arguments: ["-json"]) 82 | } 83 | } 84 | } 85 | 86 | struct DeveloperTool: Decodable, Equatable { 87 | private enum CodingKeys: String, CodingKey { 88 | case path = "spdevtools_path" 89 | case version = "spdevtools_version" 90 | } 91 | 92 | static let empty = DeveloperTool(path: "", version: "") 93 | 94 | let path: String 95 | let version: String 96 | } 97 | 98 | // swiftlint:disable nesting 99 | extension SystemProfiler { 100 | struct DeveloperToolsList: Decodable { 101 | private enum CodingKeys: String, CodingKey { 102 | case list = "SPDeveloperToolsDataType" 103 | } 104 | 105 | let list: [DeveloperTool] 106 | } 107 | 108 | } 109 | // swiftlint:enable nesting 110 | -------------------------------------------------------------------------------- /ControlRoom/Document Picker/DocumentPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentPicker.swift 3 | // ControlRoom 4 | // 5 | // Created by Manuel Rodriguez on 11/3/22. 6 | // Copyright © 2022 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | /// Basic implementation to open Finder and select file/s 13 | struct DocumentPicker { 14 | 15 | static func show(withConfig config: DocumentPickerConfig, selectedFile: ((Data) -> Void)) { 16 | let dialog = NSOpenPanel() 17 | 18 | dialog.canChooseFiles = config.canChooseFiles 19 | dialog.canChooseDirectories = config.canChooseDirectories 20 | dialog.allowedContentTypes = config.allowedContentTypes 21 | 22 | if dialog.runModal() == NSApplication.ModalResponse.OK { 23 | guard let fileURL = dialog.url, let data = try? Data(contentsOf: fileURL) else { return } 24 | 25 | selectedFile(data) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ControlRoom/Document Picker/DocumentPickerConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentPickerConfig.swift 3 | // ControlRoom 4 | // 5 | // Created by Manuel Rodriguez on 11/3/22. 6 | // Copyright © 2022 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UniformTypeIdentifiers 11 | 12 | /// Finder dialog configuration to select file/s 13 | struct DocumentPickerConfig { 14 | 15 | let showHiddenFiles: Bool 16 | let canChooseFiles: Bool 17 | let canChooseDirectories: Bool 18 | let allowedContentTypes: [UTType] 19 | 20 | init(showHiddenFiles: Bool = false, canChooseFiles: Bool = true, canChooseDirectories: Bool = false, allowedContentTypes: [UTType]) { 21 | self.showHiddenFiles = showHiddenFiles 22 | self.canChooseFiles = canChooseFiles 23 | self.canChooseDirectories = canChooseDirectories 24 | self.allowedContentTypes = allowedContentTypes 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ControlRoom/Document Picker/UTType+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTType+Extension.swift 3 | // ControlRoom 4 | // 5 | // Created by Manuel Rodriguez on 11/3/22. 6 | // Copyright © 2022 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UniformTypeIdentifiers 11 | 12 | /// Finder file extension allowed 13 | extension UTType { 14 | static let json = UTType.init(filenameExtension: "json")! 15 | } 16 | -------------------------------------------------------------------------------- /ControlRoom/Extensions/CLLocationCoordinate2D-Identifiable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLLocationCoordinate2D-Identifiable.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 28/01/2021. 6 | // Copyright © 2021 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | import Foundation 11 | 12 | extension CLLocationCoordinate2D: Identifiable { 13 | public var id: String { 14 | "\(latitude)-\(longitude)" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ControlRoom/Extensions/KeyedEncodingContainer-NotEmpty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyedEncodingContainer+.swift 3 | // ControlRoom 4 | // 5 | // Created by Mario Iannotta on 02/03/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension KeyedEncodingContainer where K: CodingKey { 12 | mutating func encodeIfNotEmpty(_ value: String, forKey key: KeyedEncodingContainer.Key) throws { 13 | guard value.isNotEmpty else { return } 14 | try encode(value, forKey: key) 15 | } 16 | 17 | mutating func encodeIfNotEmpty(_ value: [String], forKey key: KeyedEncodingContainer.Key) throws { 18 | guard value.count > 0 else { return } 19 | try encode(value, forKey: key) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Binding-OnChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Binding-OnChange.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Two extensions to make it easier to respond to changes in a binding. 12 | extension Binding { 13 | /// Updates the binding then calls a closure with the new value. 14 | func onChange(_ handler: @escaping (Value) -> Void) -> Binding { 15 | Binding( 16 | get: { self.wrappedValue }, 17 | set: { selection in 18 | self.wrappedValue = selection 19 | handler(selection) 20 | } 21 | ) 22 | } 23 | 24 | /// Updates the binding then calls a closure without the new value. 25 | func onChange(_ handler: @escaping () -> Void) -> Binding { 26 | Binding( 27 | get: { self.wrappedValue }, 28 | set: { selection in 29 | self.wrappedValue = selection 30 | handler() 31 | } 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Capture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Screenshot.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 08/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Store settings for video and screenshots 12 | struct CaptureSettings { 13 | var imageFormat: SimCtl.IO.ImageFormat 14 | var videoFormat: SimCtl.IO.VideoFormat 15 | var display: SimCtl.IO.Display 16 | var mask: SimCtl.IO.Mask 17 | var saveURL: SimCtl.IO.SaveLocation 18 | } 19 | 20 | extension CaptureSettings: RawRepresentable { 21 | public init(rawValue: String) { 22 | let components = rawValue.components(separatedBy: "~") 23 | 24 | guard components.count == 5 else { 25 | imageFormat = .png 26 | videoFormat = .h264 27 | display = .internal 28 | mask = .ignored 29 | saveURL = .desktop 30 | return 31 | } 32 | 33 | imageFormat = SimCtl.IO.ImageFormat(rawValue: components[0]) ?? .png 34 | videoFormat = SimCtl.IO.VideoFormat(rawValue: components[1]) ?? .h264 35 | display = SimCtl.IO.Display(rawValue: components[2]) ?? .internal 36 | mask = SimCtl.IO.Mask(rawValue: components[3]) ?? .ignored 37 | saveURL = SimCtl.IO.SaveLocation(rawValue: components[4]) 38 | } 39 | 40 | public var rawValue: String { 41 | let result = "\(imageFormat.rawValue)~\(videoFormat.rawValue)~\(display.rawValue)~\(mask.rawValue)~\(saveURL.rawValue)" 42 | return result 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/15/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Collection { 12 | var isNotEmpty: Bool { isEmpty == false } 13 | } 14 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/CommandLineExecuter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandLineExecuter.swift 3 | // ControlRoom 4 | // 5 | // Created by Mario Iannotta on 30/03/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | /** 13 | FYI: Using Swift 5.3 it's possible to abstract also the error with something like this 14 | ``` 15 | protocol CommandLineErrorRepresentable: Error { 16 | static var missingCommand: Self { get } 17 | static var missingOutput: Self { get } 18 | static func unknown(_ error: Error) -> Self 19 | } 20 | 21 | enum CommandLineCommandExecuterError: CommandLineErrorRepresentable 22 | case missingCommand 23 | case missingOutput 24 | case unknownError(Error) 25 | } 26 | 27 | protocol CommandLineCommandExecuter { 28 | ... 29 | associatedtype CommandLineError: CommandLineErrorRepresentable = CommandLineCommandExecuterError 30 | ... 31 | 32 | static func execute(_ arguments: [String], completion: @escaping (Result) -> Void) { 33 | .... 34 | } 35 | } 36 | ``` 37 | */ 38 | enum CommandLineError: Error { 39 | case missingCommand 40 | case missingOutput 41 | case unknown(Swift.Error) 42 | } 43 | 44 | protocol CommandLineCommand { 45 | var command: String? { get } 46 | var arguments: [String] { get } 47 | var environmentOverrides: [String: String]? { get } 48 | } 49 | 50 | protocol CommandLineCommandExecuter { 51 | associatedtype Command: CommandLineCommand 52 | static var launchPath: String { get } 53 | } 54 | 55 | extension CommandLineCommand { 56 | var command: String? { nil } 57 | } 58 | 59 | extension CommandLineCommandExecuter { 60 | 61 | private static func execute(_ command: Command, completion: @escaping (Result) -> Void) { 62 | let commandToExecute: String = command.command ?? launchPath 63 | 64 | DispatchQueue.global(qos: .userInitiated).async { 65 | if let data = Process.execute(commandToExecute, arguments: command.arguments, environmentOverrides: command.environmentOverrides) { 66 | completion(.success(data)) 67 | } else { 68 | completion(.failure(.missingCommand)) 69 | } 70 | } 71 | } 72 | 73 | static func executeAsync(_ command: Command) -> Process { 74 | let task = Process() 75 | task.launchPath = launchPath 76 | task.arguments = command.arguments 77 | 78 | let pipe = Pipe() 79 | task.standardOutput = pipe 80 | 81 | try? task.run() 82 | return task 83 | } 84 | 85 | static func executeSubject(_ command: Command) -> PassthroughSubject { 86 | let publisher = PassthroughSubject() 87 | 88 | execute(command) { result in 89 | switch result { 90 | case .success(let data): 91 | publisher.send(data) 92 | publisher.send(completion: .finished) 93 | case .failure(let error): 94 | publisher.send(completion: .failure(error)) 95 | } 96 | } 97 | 98 | return publisher 99 | } 100 | 101 | static func execute(_ command: Command, completion: ((Result) -> Void)? = nil) { 102 | execute(command, completion: completion ?? { _ in }) 103 | } 104 | 105 | static func executeJSON(_ command: Command) -> AnyPublisher { 106 | executeAndDecode(command, decoder: JSONDecoder()) 107 | } 108 | 109 | static func executePropertyList(_ command: Command) -> AnyPublisher { 110 | executeAndDecode(command, decoder: PropertyListDecoder()) 111 | } 112 | 113 | private static func executeAndDecode(_ command: Command, decoder: Decoder) -> AnyPublisher where Item: Decodable, Decoder: TopLevelDecoder, Decoder.Input == Data { 114 | executeSubject(command) 115 | .decode(type: Item.self, decoder: decoder) 116 | .mapError { error -> CommandLineError in 117 | if error is DecodingError { 118 | return .missingOutput 119 | } else if let command = error as? CommandLineError { 120 | return command 121 | } else { 122 | return .unknown(error) 123 | } 124 | } 125 | .eraseToAnyPublisher() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/ContextMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContextMenu.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/15/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension ContextMenu { 12 | init?(shouldDisplay: Bool, @ViewBuilder menuItems: () -> MenuItems) { 13 | guard shouldDisplay == true else { return nil } 14 | self.init(menuItems: menuItems) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/DeepLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLink.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A named URL with a unique identifier to make them work well with SwiftUI. 12 | struct DeepLink: Identifiable, Codable { 13 | var id: UUID 14 | var name: String 15 | var url: URL 16 | } 17 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Defaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Constant strings to store our UserDefaults keys for safer access. 12 | enum Defaults { 13 | /// The app bundle ID the user last worked with. 14 | static let bundleID = "CRBundleID" 15 | 16 | /// The app URL to open the user last entered. 17 | static let appURL = "CRAppURL" 18 | 19 | /// The flag that drives the apps filtering mode the user last entered. 20 | static let shouldDisplaySystemApps = "CRShouldDisplaySystemApps" 21 | 22 | /// The push JSON text the user last entered. 23 | static let pushPayload = "CRPushPayload" 24 | 25 | /// The cellular operator name the user last entered. 26 | static let operatorName = "CROperatorName" 27 | 28 | /// Whether the app window is floating or not 29 | static let wantsFloatingWindow = "CRWantsFloatingWindow" 30 | } 31 | 32 | /// Dynamic accessors for KVO 33 | extension UserDefaults { 34 | // Important: For some reason the property name should match the keyname in order to receive KVO observations 35 | @objc dynamic var CRWantsFloatingWindow: Bool { 36 | get { bool(forKey: Defaults.wantsFloatingWindow) } 37 | set { set(newValue, forKey: Defaults.wantsFloatingWindow) } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Double-Rounding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double-Rounding.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Double { 12 | // Rounds a Double to a specific number of decimal places, leaving 13 | // it as a Double. 14 | func rounded(dp decimalPlaces: Int) -> Double { 15 | let divisor = pow(10.0, Double(decimalPlaces)) 16 | return (self * divisor).rounded() / divisor 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/FFMPEGConverter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FFMPEGConverter.swift 3 | // ControlRoom 4 | // 5 | // Created by Nikolay Volosatov on 2.04.21. 6 | // Copyright © 2021 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum FFMPEGConverter: CommandLineCommandExecuter { 12 | static var launchPath = "/usr/local/bin/ffmpeg" 13 | 14 | struct Command: CommandLineCommand { 15 | var inPath: String 16 | var outPath: String 17 | 18 | var fps = 60 19 | var videoQuality = VideoQuality.default 20 | var videoBitrate = "2.0M" 21 | 22 | var arguments: [String] { 23 | [ 24 | "-i", inPath, // Input file 25 | "-codec:v", "libx264", // Video Codec: H.264 26 | "-b:v", videoBitrate, // Limit video bitrate 27 | "-filter:v", "fps=\(fps)", // Set video FPS 28 | "-crf", "\(videoQuality.crf)", // Set H.264 compression quality (0 - 51, smaller better) 29 | "-c:a", "copy", // Copy audio as is 30 | outPath // Output file 31 | ] 32 | } 33 | 34 | var environmentOverrides: [String: String]? { nil } 35 | } 36 | 37 | static let available: Bool = { 38 | FileManager.default.fileExists(atPath: launchPath) 39 | }() 40 | 41 | static func convert(input inPath: String, output outPath: String, 42 | callback: @escaping (Result) -> Void) { 43 | let initialSize = fileSizeString(inPath) 44 | execute(Command(inPath: inPath, outPath: outPath)) { result in 45 | switch result { 46 | case .success: 47 | let resultSize = fileSizeString(outPath) 48 | print("Video Compressed: \(initialSize) -> \(resultSize)") 49 | callback(.success(())) 50 | case .failure(let error): 51 | callback(.failure(error)) 52 | } 53 | } 54 | } 55 | 56 | static private func fileSizeString(_ path: String) -> String { 57 | guard let sizeAttribute = try? FileManager.default.attributesOfItem(atPath: path)[FileAttributeKey.size], 58 | let size = sizeAttribute as? UInt64 59 | else { 60 | return "?" 61 | } 62 | let sizeMb = Double(size) / 1024 / 1024 63 | return String(format: "%0.3f Mb", sizeMb) 64 | } 65 | } 66 | 67 | extension FFMPEGConverter { 68 | enum VideoQuality { 69 | case loseless 70 | case high 71 | case `default` 72 | case low 73 | case worstPossible 74 | case custom(Int) 75 | 76 | var crf: Int { 77 | switch self { 78 | case .loseless: 79 | return 0 80 | case .high: 81 | return 18 82 | case .default: 83 | return 23 84 | case .low: 85 | return 28 86 | case .worstPossible: 87 | return 51 88 | case .custom(let val): 89 | return val 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Flow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Flow.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/19/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | // Thank you @Zef! https://gist.github.com/zef/e48e44a3a673c36b5a0c3d0eefb676ce 10 | 11 | import SwiftUI 12 | 13 | struct CollectionView: View where Items: RandomAccessCollection, Items.Element: Identifiable, Content: View { 14 | struct Row: Identifiable { 15 | let id: Int 16 | var items: [Items.Element] 17 | } 18 | 19 | @State private var itemSizes = [SizePreference]() 20 | @State private var width: CGFloat = 0 21 | 22 | var items: Items 23 | var content: (Items.Element) -> Content 24 | 25 | var horizontalSpacing: CGFloat 26 | var horizontalAlignment: HorizontalAlignment 27 | var verticalSpacing: CGFloat 28 | 29 | var unsizedItems: [Items.Element] { 30 | itemSizes.count == items.count ? [] : Array(items) 31 | } 32 | 33 | init(_ items: Items, horizontalSpacing: CGFloat = 8, horizontalAlignment: HorizontalAlignment, verticalSpacing: CGFloat = 8, content: @escaping (Items.Element) -> Content) { 34 | self.items = items 35 | self.content = content 36 | self.horizontalSpacing = horizontalSpacing 37 | self.horizontalAlignment = horizontalAlignment 38 | self.verticalSpacing = verticalSpacing 39 | } 40 | 41 | var body: some View { 42 | VStack(alignment: horizontalAlignment, spacing: verticalSpacing) { 43 | ForEach(rows(width: width)) { row in 44 | HStack(alignment: .top, spacing: horizontalSpacing) { 45 | ForEach(row.items) { element in 46 | content(element) 47 | } 48 | } 49 | } 50 | } 51 | .frame(maxWidth: .infinity, alignment: .leading) 52 | .background( 53 | GeometryReader { proxy in 54 | // This is a phantom view that is used to calculate the item sizes. 55 | // Once calculated, they disappear from this collection and will be split into `rows` that are used above. 56 | ZStack { 57 | Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size) 58 | 59 | ForEach(unsizedItems) { element in 60 | SizePreferenceReader(id: element.id, content: content(element)) 61 | } 62 | } 63 | .onPreferenceChange(SizePreferenceListKey.self) { sizes in 64 | if sizes.count == self.items.count { 65 | // Wait until all sizes are calculated before assigning itemSizes 66 | itemSizes = sizes 67 | } 68 | } 69 | .onPreferenceChange(SizePreferenceKey.self) { size in 70 | width = size.width 71 | } 72 | } 73 | ) 74 | } 75 | 76 | func rows(width: CGFloat) -> [Row] { 77 | guard itemSizes.count == items.count else { 78 | // If itemSizes isn't yet set, return a row for each item. 79 | return items.enumerated().map { index, item in 80 | return Row(id: index, items: [item]) 81 | } 82 | } 83 | 84 | var currentRowIndex = 0 85 | var rowWidth: CGFloat = 0 86 | var rows = [Row]() 87 | 88 | for (item, size) in zip(items, itemSizes) { 89 | let thisWidth = size.size.width 90 | if (width - rowWidth - horizontalSpacing - thisWidth) >= 0, rows.isNotEmpty { 91 | var row = rows.removeLast() 92 | row.items.append(item) 93 | rows.append(row) 94 | rowWidth += horizontalSpacing + thisWidth 95 | } else { 96 | rows.append(Row(id: currentRowIndex, items: [item])) 97 | currentRowIndex += 1 98 | rowWidth = thisWidth 99 | } 100 | } 101 | 102 | return rows 103 | } 104 | } 105 | 106 | // used for storing the list of item sizes needed to display the items in rows 107 | struct SizePreference: Equatable { 108 | let id: AnyHashable 109 | let size: CGSize 110 | } 111 | 112 | struct SizePreferenceListKey: PreferenceKey { 113 | static var defaultValue = [SizePreference]() 114 | 115 | static func reduce(value: inout [SizePreference], nextValue: () -> [SizePreference]) { 116 | value.append(contentsOf: nextValue()) 117 | } 118 | } 119 | 120 | // used to store the overall width available to the CollectionView 121 | struct SizePreferenceKey: PreferenceKey { 122 | static var defaultValue: CGSize = .zero 123 | 124 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 125 | value = nextValue() 126 | } 127 | } 128 | 129 | struct SizePreferenceReader: View { 130 | var id: ID 131 | var content: V 132 | 133 | var body: some View { 134 | content.background(GeometryReader { proxy in 135 | Color.clear.preference(key: SizePreferenceListKey.self, value: [SizePreference(id: id, size: proxy.size)]) 136 | }) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/LocalSearchResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalSearchResult.swift 3 | // ControlRoom 4 | // 5 | // Created by John McEvoy on 29/11/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import MapKit 11 | 12 | /// A local search result item 13 | struct LocalSearchResult: Identifiable { 14 | var id: UUID 15 | var title: String 16 | var subtitle: String? 17 | var completer: MKLocalSearchCompletion? 18 | 19 | init(result: MKLocalSearchCompletion) { 20 | id = UUID() 21 | self.title = result.title 22 | self.subtitle = result.subtitle.clean() 23 | self.completer = result 24 | } 25 | } 26 | 27 | /// if a string is empty or whitespace, convert it to `nil` 28 | extension String { 29 | func clean() -> String? { 30 | let cleanString = self.trimmingCharacters(in: .whitespacesAndNewlines) 31 | 32 | if cleanString.isEmpty { 33 | return nil 34 | } 35 | 36 | return cleanString 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Location.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Location.swift 3 | // ControlRoom 4 | // 5 | // Created by Alexander Chekel on 17.11.2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreLocation 11 | import MapKit 12 | 13 | /// The user's saved location. 14 | struct Location: Identifiable, Codable { 15 | var id: UUID 16 | var name: String 17 | var latitude: Double 18 | var longitude: Double 19 | var latitudeDelta: Double = 15 20 | var longitudeDelta: Double = 15 21 | 22 | var center: CLLocationCoordinate2D { 23 | CLLocationCoordinate2D(latitude: latitude, longitude: longitude) 24 | } 25 | 26 | var region: MKCoordinateRegion { 27 | get { 28 | return MKCoordinateRegion( 29 | center: center, 30 | span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta)) 31 | } set { 32 | latitude = newValue.center.latitude 33 | longitude = newValue.center.longitude 34 | latitudeDelta = newValue.span.latitudeDelta 35 | longitudeDelta = newValue.span.longitudeDelta 36 | } 37 | } 38 | 39 | func toString() -> String { 40 | String(format: "%.5f, %.5f", latitude, longitude) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/NSColor-Conversions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSColor-Conversions.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | extension NSColor { 12 | /// The red color component rounded to a range of 0-255. 13 | var red255: Double { (redComponent * 255).rounded() } 14 | 15 | /// The green color component rounded to a range of 0-255. 16 | var green255: Double { (greenComponent * 255).rounded() } 17 | 18 | /// The blue color component rounded to a range of 0-255. 19 | var blue255: Double { (blueComponent * 255).rounded() } 20 | 21 | // NOTE: You might think the following three properties are 22 | // redundant, but it's just for maximum accuracy – if we used 23 | // the original redComponent, greenComponent, and blueComponent 24 | // then we would have the exact, original color, but they 25 | // wouldn't be exactly the same as the hex color. So, these 26 | // properties bounce through the 0-255 rounded variant first, 27 | // to try to keep colors uniform. 28 | 29 | /// The rounded redComponent, put back into the range of 0-1. 30 | var red1: Double { red255 / 255 } 31 | 32 | /// The rounded greenComponent, put back into the range of 0-1. 33 | var green1: Double { green255 / 255 } 34 | 35 | /// The rounded blueComponent, put back into the range of 0-1. 36 | var blue1: Double { blue255 / 255 } 37 | } 38 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/PickedColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickedColor.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A color struct that can be saved easily and also identified uniquely in SwiftUI. 12 | struct PickedColor: Identifiable, Codable { 13 | /// A unique identifier, randomly chosen to make this type easier to use with SwiftUI. 14 | var id: UUID 15 | 16 | /// The underlying NSColor data. We leave this raw to avoid losing accuracy and 17 | /// avoid dealing with color space issues. 18 | var data: Data 19 | 20 | /// Dynamically converts our NSColor data back into a real NSColor. 21 | var nsColor: NSColor { 22 | if let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: data) { 23 | return color 24 | } 25 | 26 | return .black 27 | } 28 | 29 | /// The lowercase hex representation of this color, with leading #. 30 | var hex: String { 31 | let color = nsColor 32 | 33 | let hexNumber = Int(color.red255) << 16 | Int(color.green255) << 8 | Int(color.blue255) 34 | return String(format: "#%06x", hexNumber) 35 | } 36 | 37 | /// The two-digit hex representation of the red channel, without leading #. 38 | var hexRed: String { 39 | let color = nsColor 40 | 41 | let hexNumber = Int(color.red255) 42 | return String(format: "%02x", hexNumber) 43 | } 44 | 45 | /// The two-digit hex representation of the green channel, without leading #. 46 | var hexGreen: String { 47 | let color = nsColor 48 | 49 | let hexNumber = Int(color.green255) 50 | return String(format: "%02x", hexNumber) 51 | } 52 | 53 | /// The two-digit hex representation of the blue channel, without leading #. 54 | var hexBlue: String { 55 | let color = nsColor 56 | 57 | let hexNumber = Int(color.blue255) 58 | return String(format: "%02x", hexNumber) 59 | } 60 | 61 | /// The SwiftUI.Color representation of this PickedColor instance. 62 | var swiftUIColor: Color { 63 | Color(nsColor: nsColor) 64 | } 65 | 66 | /// A sensible default color. Note: using the NSColor.black constant will trigger a crash, 67 | /// whereas creating black is fine. 68 | static var `default`: PickedColor? { 69 | PickedColor(from: NSColor(red: 0, green: 0, blue: 0, alpha: 1)) 70 | } 71 | 72 | /// Creates a new PickedColor with a random identifier and NSColor data. 73 | init(id: UUID, data: Data) { 74 | self.id = id 75 | self.data = data 76 | } 77 | 78 | /// Creates a new PickedColor with a specific NScolor instance. 79 | init?(from nsColor: NSColor) { 80 | guard let data = try? NSKeyedArchiver.archivedData(withRootObject: nsColor, requiringSecureCoding: false) else { return nil } 81 | 82 | self = PickedColor(id: UUID(), data: data) 83 | } 84 | 85 | /// Generates the code string required to recreate this color in SwiftUI. 86 | func swiftUICode(roundedTo places: Int) -> String { 87 | let color = nsColor 88 | return "Color(.sRGB, red: \(color.red1.rounded(dp: places)), green: \(color.green1.rounded(dp: places)), blue: \(color.blue1.rounded(dp: places)))" 89 | } 90 | 91 | /// Generates the code string required to recreate this color in UIKit. 92 | func uiKitCode(roundedTo places: Int) -> String { 93 | let color = nsColor 94 | return "UIColor(displayP3Red: \(color.red1.rounded(dp: places)), green: \(color.green1.rounded(dp: places)), blue: \(color.blue1.rounded(dp: places)), alpha: 1.0)" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/Process.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Process.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/14/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Process { 12 | @objc static func execute(_ command: String, arguments: [String]) -> Data? { 13 | Self.execute(command, arguments: arguments, environmentOverrides: nil) 14 | } 15 | 16 | static func execute(_ command: String, arguments: [String], environmentOverrides: [String: String]? = nil) -> Data? { 17 | let task = Process() 18 | task.launchPath = command 19 | task.arguments = arguments 20 | 21 | if let environmentOverrides { 22 | var environment = ProcessInfo.processInfo.environment 23 | environment.merge(environmentOverrides) { (_, new) in new } 24 | task.environment = environment 25 | } 26 | 27 | let pipe = Pipe() 28 | task.standardOutput = pipe 29 | 30 | do { 31 | try task.run() 32 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 33 | return data 34 | } catch { 35 | return nil 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/TypeIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypeIdentifier.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/12/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import CoreServices 11 | import UniformTypeIdentifiers 12 | 13 | struct TypeIdentifier: Hashable { 14 | static let anyDevice = TypeIdentifier("public.device") 15 | static let phone = TypeIdentifier("com.apple.iphone") 16 | static let pad = TypeIdentifier("com.apple.ipad") 17 | static let watch = TypeIdentifier("com.apple.watch") 18 | static let vision = TypeIdentifier("com.apple.vision-pro") 19 | 20 | // swiftlint:disable:next identifier_name 21 | static let tv = TypeIdentifier("com.apple.apple-tv") 22 | 23 | /// Default type identifiers to be used for unknown simulators 24 | static let defaultiPhone = TypeIdentifier("com.apple.iphone-11-pro-1") 25 | static let defaultiPad = TypeIdentifier("com.apple.ipad-pro-12point9-2") 26 | static let defaultWatch = TypeIdentifier("com.apple.watch-series5-1") 27 | static let defaultTV = TypeIdentifier("com.apple.apple-tv-4k") 28 | static let defaultVision = TypeIdentifier("com.apple.vision-pro") 29 | 30 | static func == (lhs: TypeIdentifier, rhs: TypeIdentifier) -> Bool { 31 | lhs.rawValue == rhs.rawValue 32 | } 33 | 34 | /// The string representation of the Uniform Type Identifier 35 | let rawValue: String 36 | 37 | /// Constructs an icon for this type identifier, as defined by its declaration 38 | var icon: NSImage { 39 | let type = UTType(rawValue) ?? .bundle 40 | return NSWorkspace.shared.icon(for: type) 41 | } 42 | 43 | func conformsTo(_ other: TypeIdentifier) -> Bool { 44 | let ourType = UTType(rawValue) ?? .bundle 45 | let otherType = UTType(other.rawValue) ?? .bundle 46 | return ourType.conforms(to: otherType) 47 | } 48 | 49 | /// Constructs a type identifier from a device model code, such as "iPad8,4" 50 | init?(modelIdentifier: String) { 51 | let modelTagClass = UTTagClass(rawValue: "com.apple.device-model-code") 52 | let conformingTo = UTType("public.device") 53 | 54 | let type = UTType(tag: modelIdentifier, tagClass: modelTagClass, conformingTo: conformingTo) 55 | 56 | let identifier = type?.identifier ?? "" 57 | self.init(identifier as String) 58 | } 59 | 60 | /// Constructs a type identifier based on its string representation 61 | init(_ identifier: String) { 62 | rawValue = identifier 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/URLFileAttribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLFileAttribute.swift 3 | // ControlRoom 4 | // 5 | // Created by Marcel Mendes on 14/12/24. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct URLFileAttribute { 12 | private(set) var folderSize: Int? 13 | private(set) var creationDate: Date? 14 | private(set) var modificationDate: Date? 15 | 16 | init(url: URL) { 17 | let path = url.path 18 | guard let dictionary: [FileAttributeKey: Any] = try? FileManager.default 19 | .attributesOfItem(atPath: path) else { 20 | return 21 | } 22 | 23 | if dictionary.keys.contains(FileAttributeKey.creationDate), 24 | let value = dictionary[FileAttributeKey.creationDate] as? Date { 25 | self.creationDate = value 26 | } 27 | 28 | if dictionary.keys.contains(FileAttributeKey.modificationDate), 29 | let value = dictionary[FileAttributeKey.modificationDate] as? Date { 30 | self.modificationDate = value 31 | } 32 | 33 | folderSize = getFolderSize(url: url) 34 | } 35 | 36 | private func getFolderSize(url: URL) -> Int { 37 | guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) else { return 0 } 38 | var size: Int = 0 39 | for case let fileURL as URL in enumerator { 40 | guard let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize else { 41 | continue 42 | } 43 | size += fileSize 44 | } 45 | return size 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/XcodeColorSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XcodeColorSet.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 17/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // This file describes the file format used by Xcode asset catalog 12 | // color sets. The struct names follow the format of the file, so 13 | // even though some look a little redundant it's what Xcode 14 | // is looking for. 15 | struct XcodeColorSet: Codable { 16 | var colors: [XcodeColors] 17 | var info: XcodeColorInfo 18 | 19 | /// A helper initializer to bypass the complexity of the colorset 20 | /// file structure, because only care about RGB values. 21 | init(red: String, green: String, blue: String) { 22 | self = XcodeColorSet(colors: [XcodeColors(color: XcodeColor(components: XcodeColorComponents(alpha: "1.000", blue: "0x\(blue)", green: "0x\(green)", red: "0x\(red)")))], info: XcodeColorInfo()) 23 | } 24 | 25 | /// The default initializer, where both values must be provided. 26 | private init(colors: [XcodeColors], info: XcodeColorInfo) { 27 | self.colors = colors 28 | self.info = info 29 | } 30 | } 31 | 32 | struct XcodeColors: Codable { 33 | var color: XcodeColor 34 | var idiom = "universal" 35 | } 36 | 37 | struct XcodeColor: Codable { 38 | var colorSpace = "srgb" 39 | var components: XcodeColorComponents 40 | } 41 | 42 | struct XcodeColorComponents: Codable { 43 | var alpha: String 44 | var blue: String 45 | var green: String 46 | var red: String 47 | } 48 | 49 | struct XcodeColorInfo: Codable { 50 | var author = "xcode" 51 | var version = 1 52 | } 53 | -------------------------------------------------------------------------------- /ControlRoom/Helpers/XcodeHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XcodeHelper.swift 3 | // ControlRoom 4 | // 5 | // Created by Stuart Isaac on 7/6/23. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum XcodeHelper { 12 | static func getDeveloperPath() -> String { 13 | let defaultDeveloperPath = "/Applications/Xcode.app/Contents/Developer" 14 | let developerPath: String 15 | if let developerPathData = Process.execute("/usr/bin/xcode-select", arguments: ["-p"]) { 16 | let result = String(decoding: developerPathData, as: UTF8.self).replacingOccurrences(of: "\\n+$", with: "", options: .regularExpression) 17 | developerPath = result.isEmpty ? defaultDeveloperPath : result 18 | } else { 19 | developerPath = defaultDeveloperPath 20 | } 21 | return developerPath 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ControlRoom/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Control Room 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.developer-tools 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSHumanReadableCopyright 28 | Copyright © 2023 Paul Hudson. All rights reserved. 29 | NSMainStoryboardFile 30 | Main 31 | NSPrincipalClass 32 | NSApplication 33 | NSSupportsAutomaticTermination 34 | 35 | NSSupportsSuddenTermination 36 | 37 | NSAppleEventsUsageDescription 38 | Do you want to allow automation for this app? 39 | 40 | 41 | -------------------------------------------------------------------------------- /ControlRoom/Loading/LoadingFailedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingFailedView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Shown when loading the simulator data from simctl has failed. 12 | struct LoadingFailedView: View { 13 | let title: String 14 | let text: String 15 | 16 | var body: some View { 17 | VStack(spacing: 10) { 18 | Text(title) 19 | .multilineTextAlignment(.center) 20 | .font(.headline) 21 | .padding(.horizontal) 22 | 23 | Text(text) 24 | .multilineTextAlignment(.center) 25 | .padding(.horizontal) 26 | } 27 | } 28 | } 29 | 30 | struct LoadingFailed_Previews: PreviewProvider { 31 | static var previews: some View { 32 | LoadingFailedView(title: "Lorem ipsum", text: "Dolor sit amet") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ControlRoom/Loading/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Shown when the app launches, while simulator data is being fetched from simctl. 12 | struct LoadingView: View { 13 | var body: some View { 14 | Text("Fetching simulator list…") 15 | .padding() 16 | ProgressView() 17 | .progressViewStyle(CircularProgressViewStyle(tint: .gray)) 18 | .controlSize(.large) 19 | } 20 | } 21 | 22 | struct LoadingView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | LoadingView() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/CreateSimulatorActionSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateSimulatorActionSheet.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/15/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CreateSimulatorActionSheet: View { 12 | let controller: SimulatorsController 13 | 14 | @State private var deviceType: DeviceType 15 | @State private var runtime: Runtime 16 | @State private var name: String = "" 17 | 18 | init(controller: SimulatorsController) { 19 | self.controller = controller 20 | _deviceType = State(initialValue: controller.deviceTypes[0]) 21 | _runtime = State(initialValue: controller.runtimes[0]) 22 | } 23 | 24 | private var canCreate: Bool { 25 | name.isNotEmpty && warning == nil 26 | } 27 | 28 | private var warning: String? { 29 | let supportedFamilies = runtime.supportedFamilies 30 | if supportedFamilies.contains(deviceType.family) { return nil } 31 | 32 | let familyList = ListFormatter().string(from: supportedFamilies.map(\.displayName))! 33 | return "\(runtime.name) can only be used with \(familyList) devices." 34 | } 35 | 36 | var body: some View { 37 | SimulatorActionSheet( 38 | icon: (deviceType.modelTypeIdentifier ?? .defaultiPhone).icon, 39 | message: "Create Simulator", 40 | informativeText: "Choose the device type and operating system for the new simulator", 41 | confirmationTitle: "Create", 42 | confirm: confirm, 43 | canConfirm: canCreate, 44 | content: { 45 | Form { 46 | TextField("Name", text: $name) 47 | 48 | Picker("Device", selection: $deviceType) { 49 | ForEach(controller.deviceTypes) { 50 | Text($0.name).tag($0) 51 | } 52 | } 53 | 54 | Picker("System", selection: $runtime) { 55 | ForEach(controller.runtimes) { 56 | Text($0.name).tag($0) 57 | } 58 | } 59 | 60 | if let warning { 61 | HStack(alignment: .top) { 62 | Image(nsImage: NSImage(named: NSImage.cautionName)!) 63 | .resizable() 64 | .aspectRatio(1.0, contentMode: .fit) 65 | .frame(width: 18) 66 | Text(warning) 67 | } 68 | } 69 | } 70 | } 71 | ) 72 | } 73 | 74 | private func confirm() { 75 | SimCtl.create(name: name, deviceType: deviceType, runtime: runtime) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Hosts a LoadingView followed by the main ControlView, or a LoadingFailedView if simctl failed. 12 | struct MainView: View { 13 | @ObservedObject var controller: SimulatorsController 14 | @EnvironmentObject var uiState: UIState 15 | 16 | var body: some View { 17 | Group { 18 | switch controller.loadingStatus { 19 | case .failed: 20 | LoadingFailedView( 21 | title: "Loading failed", 22 | text: "This usually happens because the command /usr/bin/xcrun can't be found." 23 | ) 24 | case .invalidCommandLineTool: 25 | LoadingFailedView( 26 | title: "Loading failed. You need to use Xcode 11.4+ and install the command line tools.", 27 | text: "If you already have Xcode 11.4+ installed, go to Xcode's Preferences, choose the Locations tab, then make sure Xcode is selected for Command Line Tools." 28 | ) 29 | case .success: 30 | SplitLayoutView(controller: controller) 31 | default: 32 | LoadingView() 33 | } 34 | } 35 | .frame(minWidth: 800, maxWidth: .infinity, minHeight: 550, maxHeight: .infinity) 36 | .sheet(item: $uiState.currentSheet, content: sheetView) 37 | .alert(item: $uiState.currentAlert, content: alert) 38 | } 39 | 40 | private func sheetView(for sheet: UIState.Sheet) -> some View { 41 | Group { 42 | switch sheet { 43 | case .preferences: 44 | SettingsView() 45 | case .createSimulator: 46 | CreateSimulatorActionSheet(controller: controller) 47 | case .deepLinkEditor: 48 | DeepLinkEditorView() 49 | case .notificationEditor: 50 | NotificationEditorView() 51 | case .confirmDeleteSelected: 52 | SimulatorActionSheet( 53 | icon: controller.selectedSimulators[0].image, 54 | message: "Delete Simulators?", 55 | informativeText: "Are you sure you want to delete the selected simulators? You will not be able to undo this action.", 56 | confirmationTitle: "Delete", 57 | confirm: deleteSelectedSimulators, 58 | content: EmptyView.init 59 | ) 60 | } 61 | } 62 | } 63 | /// Deletes all simulators that are currently selected. 64 | func deleteSelectedSimulators() { 65 | guard controller.selectedSimulatorIDs.isNotEmpty else { return } 66 | SimCtl.delete(controller.selectedSimulatorIDs) 67 | } 68 | 69 | private func alert(for alert: UIState.Alert) -> Alert { 70 | if alert == .confirmDeleteUnavailable { 71 | let confirmButton = Alert.Button.default(Text("Confirm")) { 72 | SimCtl.execute(.delete(.unavailable)) 73 | } 74 | 75 | return Alert(title: Text("Are you sure you want to delete all unavailable simulators?"), primaryButton: confirmButton, secondaryButton: .cancel()) 76 | } else { 77 | return Alert(title: Text("Unknown Alert")) 78 | } 79 | } 80 | } 81 | 82 | struct MainView_Previews: PreviewProvider { 83 | static var previews: some View { 84 | MainView(controller: SimulatorsController(preferences: Preferences())) 85 | .environmentObject(UIState.shared) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/MainWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainWindowController.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/16/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Combine 11 | import SwiftUI 12 | 13 | class MainWindowController: NSWindowController { 14 | // Without this, AppKit won't call -loadWindow 15 | override var windowNibName: NSNib.Name? { "None" } 16 | 17 | lazy var preferences: Preferences = Preferences() 18 | lazy var controller: SimulatorsController = SimulatorsController(preferences: preferences) 19 | 20 | private var cancellables = Set() 21 | 22 | init() { 23 | super.init(window: nil) 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | private func windowContent() -> some View { 31 | MainView(controller: controller) 32 | .environmentObject(preferences) 33 | .environmentObject(UIState.shared) 34 | } 35 | 36 | override func loadWindow() { 37 | // Create the window and set the content view. 38 | let window = NSWindow( 39 | contentRect: NSRect(x: 0, y: 0, width: 950, height: 600), 40 | styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], 41 | backing: .buffered, defer: false) 42 | window.setFrameAutosaveName("Main Window") 43 | window.contentView = NSHostingView(rootView: windowContent()) 44 | window.title = "Control Room" 45 | // window.isMovableByWindowBackground = true 46 | 47 | // disable the system-generated tab bar menu items, because we can't use them 48 | // window.tabbingMode = .disallowed 49 | 50 | self.window = window 51 | adjustWindowLevel() 52 | 53 | // note this is a DID change publisher, not a WILL change publisher 54 | preferences.objectDidChange.sink(receiveValue: { [weak self] in 55 | self?.adjustWindowLevel() 56 | }).store(in: &cancellables) 57 | } 58 | 59 | private func adjustWindowLevel() { 60 | window?.level = preferences.wantsFloatingWindow ? .floating : .normal 61 | } 62 | 63 | @IBAction func toggleFloatingWindow(_ sender: Any) { 64 | preferences.wantsFloatingWindow.toggle() 65 | } 66 | 67 | @IBAction func showPreferences(_ sender: Any) { 68 | UIState.shared.currentSheet = .preferences 69 | } 70 | 71 | @IBAction func newSimulator(_ sender: Any) { 72 | UIState.shared.currentSheet = .createSimulator 73 | } 74 | @IBAction func deleteSelectedSimulators(_ sender: Any) { 75 | UIState.shared.currentSheet = .confirmDeleteSelected 76 | } 77 | 78 | @IBAction func deleteUnavailable(_ sender: Any) { 79 | UIState.shared.currentAlert = .confirmDeleteUnavailable 80 | } 81 | 82 | @IBAction func showHelp(_ sender: Any) { 83 | NSWorkspace.shared.open(URL(string: "https://github.com/twostraws/ControlRoom")!) 84 | } 85 | } 86 | 87 | extension MainWindowController: NSMenuItemValidation { 88 | func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { 89 | if menuItem.action == #selector(toggleFloatingWindow(_:)) { 90 | menuItem.state = preferences.wantsFloatingWindow ? .on : .off 91 | return true 92 | } 93 | 94 | if menuItem.action == #selector(newSimulator(_:)) { 95 | return controller.loadingStatus == .success 96 | } 97 | 98 | return responds(to: menuItem.action) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/SidebarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarView.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/12/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Shows the list of available simulators, allowing selection, filtering, and deletion. 12 | struct SidebarView: View { 13 | @EnvironmentObject var preferences: Preferences 14 | @ObservedObject var controller: SimulatorsController 15 | 16 | @AppStorage("CRSidebar_FilterText") private var filterText = "" 17 | @AppStorage("CRLastSimulatorUDID") private var lastSimulatorUDID = "booted" 18 | 19 | @State private var shouldShowDeleteAlert = false 20 | 21 | private var selectedSimulatorsSummary: String { 22 | guard controller.selectedSimulators.count > 0 else { return "" } 23 | 24 | switch controller.selectedSimulators.count { 25 | case 1: 26 | return controller.selectedSimulators[0].summary 27 | default: 28 | let simulatorsSummaries = controller.selectedSimulators.map { "• \($0.summary)" }.joined(separator: "\n") 29 | return "the following simulators? \n\n\(simulatorsSummaries)" 30 | } 31 | } 32 | 33 | var body: some View { 34 | VStack(spacing: 0) { 35 | List(selection: $controller.selectedSimulatorIDs.onChange(updateSelectedSimulators)) { 36 | if controller.simulators.isEmpty { 37 | Text("No simulators") 38 | } else { 39 | ForEach(SimCtl.DeviceFamily.allCases, id: \.self, content: section) 40 | } 41 | } 42 | .contextMenu { 43 | if controller.selectedSimulatorIDs.isNotEmpty { 44 | Button("Delete...") { 45 | shouldShowDeleteAlert = true 46 | } 47 | } 48 | } 49 | .listStyle(.sidebar) 50 | 51 | Divider() 52 | 53 | HStack(spacing: 4) { 54 | Button { 55 | preferences.shouldShowOnlyActiveDevices.toggle() 56 | controller.filterSimulators() 57 | } label: { 58 | Image(systemName: "power") 59 | .resizable() 60 | .foregroundColor(preferences.shouldShowOnlyActiveDevices ? .accentColor : .secondary) 61 | .aspectRatio(contentMode: .fit) 62 | .frame(width: 16) 63 | .padding(.horizontal, 2) 64 | } 65 | .buttonStyle(.borderless) 66 | .padding(.leading, 3) 67 | .help("Show \(preferences.shouldShowOnlyActiveDevices ? "all" : "only active") devices") 68 | 69 | SearchField("Filter", text: $filterText.onChange(controller.filterSimulators), onClear: {}) 70 | } 71 | .padding(2) 72 | .sheet(isPresented: $shouldShowDeleteAlert) { 73 | SimulatorActionSheet( 74 | icon: controller.selectedSimulators[0].image, 75 | message: "Delete Simulators?", 76 | informativeText: "Are you sure you want to delete the selected simulators? You will not be able to undo this action.", 77 | confirmationTitle: "Delete", 78 | confirm: deleteSelectedSimulators, 79 | content: { EmptyView() } 80 | ) 81 | } 82 | } 83 | } 84 | 85 | private func section(for family: SimCtl.DeviceFamily) -> some View { 86 | let simulators = controller.simulators.filter { $0.deviceFamily == family } 87 | let canShowContext = controller.selectedSimulatorIDs.count < 2 88 | 89 | return Group { 90 | if simulators.isEmpty { 91 | EmptyView() 92 | } else { 93 | Section(header: Text(family.displayName)) { 94 | ForEach(simulators) { simulator in 95 | SimulatorSidebarView(simulator: simulator, canShowContextualMenu: canShowContext) 96 | .tag(simulator.udid) 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | /// Deletes all simulators that are currently selected. 104 | func deleteSelectedSimulators() { 105 | guard controller.selectedSimulatorIDs.isNotEmpty else { return } 106 | SimCtl.delete(controller.selectedSimulatorIDs) 107 | } 108 | 109 | /// Called whenever the user adjusts their selection of simulator. 110 | func updateSelectedSimulators() { 111 | // If we selected exactly one simulator, stash its UDID away so we can 112 | // quickly use it elsewhere in the app, e.g. in the menu bar icon. 113 | if controller.selectedSimulatorIDs.count == 1 { 114 | lastSimulatorUDID = controller.selectedSimulators.first!.udid 115 | } 116 | } 117 | } 118 | 119 | private extension Simulator { 120 | var summary: String { 121 | [name, runtime?.name].compactMap { $0 }.joined(separator: " - ") 122 | } 123 | } 124 | 125 | struct SidebarView_Previews: PreviewProvider { 126 | @State static var selected: Simulator? 127 | 128 | static var previews: some View { 129 | let preferences = Preferences() 130 | return SidebarView(controller: SimulatorsController(preferences: preferences)) 131 | .environmentObject(preferences) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/SimulatorAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimulatorAction.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 28/01/2021. 6 | // Copyright © 2021 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import struct SwiftUI.LocalizedStringKey 10 | 11 | enum Action: Int, Identifiable { 12 | case power 13 | case rename 14 | case clone 15 | case createSnapshot 16 | case delete 17 | case openRoot 18 | 19 | var id: Int { rawValue } 20 | 21 | var sheetTitle: LocalizedStringKey { 22 | switch self { 23 | case .power: "" 24 | case .rename: "Rename Simulator" 25 | case .clone: "Clone Simulator" 26 | case .createSnapshot: "Create Snapshot" 27 | case .delete: "Delete Simulator" 28 | case .openRoot: "" 29 | } 30 | } 31 | 32 | var sheetMessage: LocalizedStringKey { 33 | switch self { 34 | case .power: "" 35 | case .rename: "Enter a new name for this simulator. It may be the same as the name of an existing simulator, but a unique name will make it easier to identify." 36 | case .clone: "Enter a name for the new simulator. It may be the same as the name of an existing simulator, but a unique name will make it easier to identify." 37 | case .createSnapshot: "" 38 | case .delete: "Are you sure you want to delete this simulator? You will not be able to undo this action." 39 | case .openRoot: "" 40 | } 41 | } 42 | 43 | var saveActionTitle: LocalizedStringKey { 44 | switch self { 45 | case .power: "Power" 46 | case .rename: "Rename" 47 | case .clone: "Clone" 48 | case .createSnapshot: "Create" 49 | case .delete: "Delete" 50 | case .openRoot: "" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/SimulatorActionSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimulatorActionSheet.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/15/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SimulatorActionSheet: View { 12 | @Environment(\.presentationMode) var presentationMode 13 | 14 | let icon: NSImage 15 | let message: LocalizedStringKey 16 | let informativeText: LocalizedStringKey 17 | let content: Content 18 | 19 | let confirmationTitle: LocalizedStringKey 20 | let confirmationAction: () -> Void 21 | let canConfirm: Bool 22 | 23 | internal init(icon: NSImage, 24 | message: LocalizedStringKey, 25 | informativeText: LocalizedStringKey, 26 | confirmationTitle: LocalizedStringKey, 27 | confirm: @escaping () -> Void, 28 | canConfirm: Bool = true, 29 | @ViewBuilder content: () -> Content) { 30 | self.icon = icon 31 | self.message = message 32 | self.informativeText = informativeText 33 | self.content = content() 34 | self.canConfirm = canConfirm 35 | self.confirmationTitle = confirmationTitle 36 | self.confirmationAction = confirm 37 | } 38 | 39 | var body: some View { 40 | VStack(alignment: .leading, spacing: 10) { 41 | HStack(alignment: .top, spacing: 10) { 42 | Image(nsImage: icon) 43 | .resizable() 44 | .aspectRatio(icon.size, contentMode: .fit) 45 | .frame(width: 48) 46 | 47 | VStack(alignment: .leading) { 48 | Text(message) 49 | .fontWeight(.bold) 50 | .lineLimit(1) 51 | 52 | Text(informativeText) 53 | .multilineTextAlignment(.leading) 54 | 55 | content 56 | } 57 | } 58 | 59 | HStack { 60 | Button("Cancel", action: dismiss) 61 | .keyboardShortcut(.cancelAction) 62 | Spacer() 63 | Button(confirmationTitle) { 64 | confirmationAction() 65 | dismiss() 66 | } 67 | .disabled(canConfirm == false) 68 | .keyboardShortcut(.defaultAction) 69 | } 70 | } 71 | .fixedSize(horizontal: false, vertical: true) 72 | .frame(minWidth: 300, idealWidth: 400) 73 | .padding(20) 74 | } 75 | 76 | private func dismiss() { 77 | presentationMode.wrappedValue.dismiss() 78 | } 79 | } 80 | 81 | extension SimulatorActionSheet where Content == EmptyView { 82 | internal init(icon: NSImage, 83 | message: LocalizedStringKey, 84 | informativeText: LocalizedStringKey, 85 | confirmationTitle: LocalizedStringKey, 86 | confirm: @escaping () -> Void) { 87 | 88 | self.init(icon: icon, message: message, informativeText: informativeText, confirmationTitle: confirmationTitle, confirm: confirm, content: { EmptyView() }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/SimulatorSidebarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimulatorSidebarView.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/12/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import KeyboardShortcuts 11 | 12 | /// Shows one simulator in the sidebar. 13 | struct SimulatorSidebarView: View { 14 | var simulator: Simulator 15 | let canShowContextualMenu: Bool 16 | 17 | @State private var action: Action? 18 | @State private var newName: String 19 | 20 | init(simulator: Simulator, canShowContextualMenu: Bool) { 21 | self.simulator = simulator 22 | self.canShowContextualMenu = canShowContextualMenu 23 | self._newName = State(initialValue: simulator.name) 24 | } 25 | 26 | private var simulatorSummary: String { 27 | [simulator.name, (simulator.runtime?.version).map { "(\($0))" }] 28 | .compactMap { $0 } 29 | .joined(separator: " ") 30 | } 31 | 32 | private var statusImage: NSImage { 33 | let name: NSImage.Name 34 | 35 | switch simulator.state { 36 | case .booting: 37 | name = NSImage.statusPartiallyAvailableName 38 | case .shuttingDown: 39 | name = NSImage.statusPartiallyAvailableName 40 | case .booted: 41 | name = NSImage.statusAvailableName 42 | default: 43 | name = NSImage.statusNoneName 44 | } 45 | 46 | return NSImage(named: name)! 47 | } 48 | 49 | var body: some View { 50 | HStack(spacing: 2) { 51 | Image(nsImage: statusImage) 52 | Image(nsImage: simulator.image) 53 | .resizable() 54 | .aspectRatio(1.0, contentMode: .fit) 55 | .frame(maxWidth: 24, alignment: .center) 56 | .padding(.top, 2) 57 | .shadow(color: .primary, radius: 1) 58 | Text(simulatorSummary) 59 | } 60 | .frame(alignment: .leading) 61 | .contextMenu( 62 | ContextMenu(shouldDisplay: canShowContextualMenu) { 63 | Button("\(simulator.state.menuActionName)") { performAction(.power) } 64 | .disabled(!simulator.state.isActionAllowed) 65 | Divider() 66 | Button("Rename...") { action = .rename } 67 | Button("Clone...") { action = .clone } 68 | .disabled(simulator.state == .booted) 69 | Button("Create snapshot...") { performAction(.createSnapshot) } 70 | Button("Delete...") { action = .delete } 71 | Divider() 72 | Button("Open in Finder") { performAction(.openRoot) } 73 | } 74 | ) 75 | .sheet(item: $action) { action in 76 | switch action { 77 | case .power, .openRoot, .createSnapshot: 78 | EmptyView() 79 | case .rename, .clone: 80 | SimulatorActionSheet( 81 | icon: simulator.image, 82 | message: action.sheetTitle, 83 | informativeText: action.sheetMessage, 84 | confirmationTitle: action.saveActionTitle, 85 | confirm: { performAction(action) }, 86 | canConfirm: newName.isNotEmpty, 87 | content: { 88 | TextField("Name", text: $newName) 89 | } 90 | ) 91 | case .delete: 92 | SimulatorActionSheet( 93 | icon: simulator.image, 94 | message: action.sheetTitle, 95 | informativeText: action.sheetMessage, 96 | confirmationTitle: action.saveActionTitle, 97 | confirm: { performAction(action) }) 98 | } 99 | } 100 | } 101 | 102 | private func performAction(_ action: Action) { 103 | guard newName.isNotEmpty else { return } 104 | 105 | switch action { 106 | case .rename: SimCtl.rename(simulator.udid, name: newName) 107 | case .clone: SimCtl.clone(simulator.udid, name: newName) 108 | case .createSnapshot: SnapshotCtl.createSnapshot(deviceId: simulator.udid, snapshotName: UUID().uuidString) 109 | case .delete: SimCtl.delete([simulator.udid]) 110 | case .power: 111 | if simulator.state == .booted { 112 | SimCtl.shutdown(simulator.udid) 113 | } else if simulator.state == .shutdown { 114 | SimCtl.boot(simulator) 115 | } 116 | case .openRoot: 117 | simulator.open(.root) 118 | } 119 | } 120 | } 121 | 122 | struct SimulatorSidebarView_Previews: PreviewProvider { 123 | static var previews: some View { 124 | SimulatorSidebarView(simulator: .example, canShowContextualMenu: true) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /ControlRoom/Main Window/SplitLayoutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitLayoutView.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/12/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A horizontal split view that shows a left-hand sidebar of simulators and right-hand details. 12 | struct SplitLayoutView: View { 13 | @ObservedObject var controller: SimulatorsController 14 | 15 | @State private var dropHovering: Bool = false 16 | 17 | var body: some View { 18 | NavigationSplitView { 19 | SidebarView(controller: controller) 20 | .frame(minWidth: 220) 21 | } detail: { 22 | // Use a GeometryReader here to take up as much space as possible 23 | // otherwise the view would collapse down to (potentially) 24 | // the size of the Text. 25 | Group { 26 | switch controller.selectedSimulatorIDs.count { 27 | case 0: 28 | Text("Select a simulator from the list.") 29 | .frame(maxWidth: .infinity, maxHeight: .infinity) 30 | case 1: 31 | ControlView(controller: controller, 32 | simulator: controller.selectedSimulators[0], 33 | applications: controller.applications) 34 | .padding() 35 | default: 36 | Text("Drag file(s) here to copy them to each simulator's Files directory.\n(booted simulators only)") 37 | .multilineTextAlignment(.center) 38 | .padding(20) 39 | .overlay( 40 | RoundedRectangle(cornerRadius: 5) 41 | .stroke(dropHovering ? Color.white : Color.gray, lineWidth: 1) 42 | ) 43 | .onDrop(of: [.fileURL], isTargeted: $dropHovering) { providers in 44 | return copyFilesFromProviders(providers, toFilePath: .files) 45 | } 46 | .frame(maxWidth: .infinity, maxHeight: .infinity) 47 | } 48 | } 49 | } 50 | } 51 | 52 | func copyFilesFromProviders(_ providers: [NSItemProvider], toFilePath filePath: Simulator.FilePathKind) -> Bool { 53 | for simulator in controller.selectedSimulators { 54 | _ = simulator.copyFilesFromProviders(providers, toFilePath: filePath) 55 | } 56 | return true 57 | } 58 | } 59 | 60 | struct SplitLayoutView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | let preferences = Preferences() 63 | SplitLayoutView(controller: SimulatorsController(preferences: preferences)) 64 | .environmentObject(preferences) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ControlRoom/NSViewWrappers/SearchField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterField.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/12/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A wrapper around NSSearchField so we get a macOS-native search box 12 | struct SearchField: NSViewRepresentable { 13 | /// The text entered by the user. 14 | @Binding var text: String 15 | var onClear: () -> Void 16 | 17 | /// Placeholder text for the text field. 18 | let prompt: String 19 | 20 | init(_ prompt: String, text: Binding, onClear: @escaping () -> Void) { 21 | self.onClear = onClear 22 | self.prompt = prompt 23 | _text = text 24 | } 25 | 26 | func makeCoordinator() -> Coordinator { 27 | Coordinator(binding: $text, onClear: onClear) 28 | } 29 | 30 | func makeNSView(context: Context) -> NSSearchField { 31 | let textField = NSSearchField(string: text) 32 | textField.placeholderString = prompt 33 | textField.delegate = context.coordinator 34 | textField.bezelStyle = .roundedBezel 35 | textField.focusRingType = .none 36 | return textField 37 | } 38 | 39 | func updateNSView(_ nsView: NSSearchField, context: Context) { 40 | nsView.stringValue = text 41 | } 42 | 43 | class Coordinator: NSObject, NSSearchFieldDelegate { 44 | let binding: Binding 45 | let onClear: () -> Void 46 | 47 | init(binding: Binding, onClear: @escaping () -> Void) { 48 | self.binding = binding 49 | self.onClear = onClear 50 | super.init() 51 | } 52 | 53 | func controlTextDidChange(_ obj: Notification) { 54 | guard let field = obj.object as? NSTextField else { return } 55 | binding.wrappedValue = field.stringValue 56 | 57 | if field.stringValue.isEmpty { 58 | onClear() 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ControlRoom/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ControlRoom/Settings UI/ColorPickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorPickerView.swift 3 | // ControlRoom 4 | // 5 | // Created by Elliot Knight on 11/05/2024. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ColorPickerView: View { 12 | /// Whether hex strings should be printed in uppercase or not. 13 | @AppStorage("CRColorPickerUppercaseHex") var uppercaseHex = true 14 | 15 | /// How many decimal places to use for rounding picked colors. 16 | @AppStorage("CRColorPickerAccuracy") var colorPickerAccuracy = 2 17 | 18 | var body: some View { 19 | VStack { 20 | Toggle("Uppercase Hex Strings", isOn: $uppercaseHex) 21 | .padding(.bottom) 22 | 23 | Text("Set the maximum number of decimal places to use when generating code for picked simulator colors. The default is 2.") 24 | Stepper("Decimal Places: \(colorPickerAccuracy)", value: $colorPickerAccuracy, in: 0...5) 25 | .pickerStyle(.segmented) 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | ColorPickerView() 32 | } 33 | -------------------------------------------------------------------------------- /ControlRoom/Settings UI/NotificationsFormView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsFormView.swift 3 | // ControlRoom 4 | // 5 | // Created by Elliot Knight on 11/05/2024. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import KeyboardShortcuts 11 | 12 | struct NotificationsFormView: View { 13 | var body: some View { 14 | Form { 15 | makeKeyboardShortcut(title: "Resend last push notification", for: .resendLastPushNotification) 16 | makeKeyboardShortcut(title: "Restart last selected app", for: .restartLastSelectedApp) 17 | makeKeyboardShortcut(title: "Reopen last URL", for: .reopenLastURL) 18 | } 19 | } 20 | 21 | private func makeKeyboardShortcut(title: String, for name: KeyboardShortcuts.Name) -> some View { 22 | HStack { 23 | Text(title) 24 | KeyboardShortcuts.Recorder(for: name) 25 | } 26 | } 27 | } 28 | 29 | #Preview { 30 | NotificationsFormView() 31 | } 32 | -------------------------------------------------------------------------------- /ControlRoom/Settings UI/PathToTerminalTextFieldView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PathToTerminalTextFieldView.swift 3 | // ControlRoom 4 | // 5 | // Created by Elliot Knight on 11/05/2024. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PathToTerminalTextFieldView: View { 12 | @EnvironmentObject var preferences: Preferences 13 | 14 | var body: some View { 15 | Form { 16 | TextField( 17 | "Path to Terminal", 18 | text: $preferences.terminalAppPath 19 | ) 20 | .textFieldStyle(.roundedBorder) 21 | } 22 | } 23 | } 24 | 25 | #Preview { 26 | PathToTerminalTextFieldView() 27 | .environmentObject(Preferences()) 28 | } 29 | -------------------------------------------------------------------------------- /ControlRoom/Settings UI/PickersFormView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickersFormView.swift 3 | // ControlRoom 4 | // 5 | // Created by Elliot Knight on 11/05/2024. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PickersFormView: View { 12 | /// The user's settings for capturing 13 | @AppStorage("captureSettings") var captureSettings = CaptureSettings(imageFormat: .png, videoFormat: .h264, display: .internal, mask: .ignored, saveURL: .desktop) 14 | 15 | /// Whether the user wants us to render device bezels around their screenshots. 16 | /// Note: this requires a mask of alpha, so we enforce that when true. 17 | @AppStorage("renderChrome") var renderChrome = false 18 | @State private var showFileImporter = false 19 | 20 | var body: some View { 21 | Form { 22 | Picker("Screenshot Format:", selection: $captureSettings.imageFormat) { 23 | ForEach(SimCtl.IO.ImageFormat.allCases, id: \.self) { type in 24 | Text(type.rawValue.uppercased()).tag(type) 25 | } 26 | } 27 | 28 | Picker("Video Format:", selection: $captureSettings.videoFormat) { 29 | ForEach(SimCtl.IO.VideoFormat.all, id: \.self) { item in 30 | if item == .divider { 31 | Divider() 32 | } else { 33 | Text(item.name).tag(item) 34 | } 35 | } 36 | } 37 | 38 | Picker("Display:", selection: $captureSettings.display) { 39 | ForEach(SimCtl.IO.Display.allCases, id: \.self) { display in 40 | Text(display.rawValue.capitalized).tag(display) 41 | } 42 | } 43 | 44 | Picker("Mask:", selection: $captureSettings.mask) { 45 | ForEach(SimCtl.IO.Mask.allCases, id: \.self) { mask in 46 | Text(mask.rawValue.capitalized).tag(mask) 47 | } 48 | } 49 | .disabled(renderChrome) 50 | 51 | Button("Save to: \(captureSettings.saveURL.rawValue)") { 52 | showFileImporter = true 53 | } 54 | 55 | Toggle(isOn: $renderChrome.onChange(updateChromeSettings)) { 56 | VStack(alignment: .leading) { 57 | Text("Add device chrome to screenshots") 58 | Text("This is an experimental feature and may not function properly yet.") 59 | .font(.caption) 60 | } 61 | } 62 | } 63 | .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.directory]) { result in 64 | switch result { 65 | case .success(let success): 66 | captureSettings.saveURL = .other(success) 67 | case .failure: 68 | captureSettings.saveURL = .desktop 69 | } 70 | } 71 | } 72 | 73 | private func updateChromeSettings() { 74 | if renderChrome { 75 | captureSettings.mask = .alpha 76 | } 77 | } 78 | } 79 | 80 | #Preview { 81 | PickersFormView() 82 | } 83 | -------------------------------------------------------------------------------- /ControlRoom/Settings UI/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // ControlRoom 4 | // 5 | // Created by Dave DeLong on 2/16/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import KeyboardShortcuts 10 | import SwiftUI 11 | 12 | struct SettingsView: View { 13 | var body: some View { 14 | TabView { 15 | TogglesFormView() 16 | .padding() 17 | .frame(maxWidth: .infinity, maxHeight: .infinity) 18 | .tabItem { 19 | Label("Window", systemImage: "macwindow") 20 | } 21 | 22 | NotificationsFormView() 23 | .padding() 24 | .frame(maxWidth: .infinity, maxHeight: .infinity) 25 | .tabItem { 26 | Label("Shortcuts", systemImage: "keyboard") 27 | } 28 | 29 | PickersFormView() 30 | .padding() 31 | .frame(maxWidth: .infinity, maxHeight: .infinity) 32 | .tabItem { 33 | Label("Screenshots", systemImage: "camera.on.rectangle") 34 | } 35 | 36 | ColorPickerView() 37 | .padding() 38 | .frame(maxWidth: .infinity, maxHeight: .infinity) 39 | .tabItem { 40 | Label("Colors", systemImage: "paintpalette") 41 | } 42 | 43 | PathToTerminalTextFieldView() 44 | .padding() 45 | .frame(maxWidth: .infinity, maxHeight: .infinity) 46 | .tabItem { 47 | Label("Locations", systemImage: "externaldrive") 48 | } 49 | } 50 | .frame(minWidth: 550) 51 | } 52 | } 53 | 54 | #Preview { 55 | SettingsView() 56 | .environmentObject(Preferences()) 57 | } 58 | -------------------------------------------------------------------------------- /ControlRoom/Settings UI/TogglesFormView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TogglesFormView.swift 3 | // ControlRoom 4 | // 5 | // Created by Elliot Knight on 11/05/2024. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TogglesFormView: View { 12 | @EnvironmentObject private var preferences: Preferences 13 | var body: some View { 14 | Form { 15 | Toggle("Keep window on top", isOn: $preferences.wantsFloatingWindow) 16 | Toggle("Show Default simulator", isOn: $preferences.showDefaultSimulator) 17 | Toggle("Show booted devices first", isOn: $preferences.showBootedDevicesFirst) 18 | Toggle("Show icon in menu bar", isOn: $preferences.wantsMenuBarIcon) 19 | } 20 | } 21 | } 22 | 23 | #Preview { 24 | TogglesFormView() 25 | .environmentObject(Preferences()) 26 | } 27 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/AppView/AppIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppIcon.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 28/01/2021. 6 | // Copyright © 2021 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AppIcon: View { 12 | let application: Application 13 | let width: CGFloat 14 | 15 | var body: some View { 16 | if let icon = application.icon { 17 | Image(nsImage: icon) 18 | .resizable() 19 | .cornerRadius(width / 5) 20 | .frame(width: width, height: width) 21 | } else { 22 | Rectangle() 23 | .fill(Color.clear) 24 | .overlay( 25 | RoundedRectangle(cornerRadius: width / 5) 26 | .stroke(Color.primary, style: StrokeStyle(lineWidth: 0.5, dash: [width / 20 + 1])) 27 | ) 28 | .frame(width: width, height: width) 29 | } 30 | } 31 | } 32 | 33 | struct AppIcon_Previews: PreviewProvider { 34 | static var previews: some View { 35 | AppIcon(application: Application.default, width: 100) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/AppView/AppSummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppSummaryView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 28/01/2021. 6 | // Copyright © 2021 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AppSummaryView: View { 12 | let application: Application 13 | 14 | var body: some View { 15 | HStack { 16 | AppIcon(application: application, width: 60) 17 | 18 | VStack(alignment: .leading) { 19 | Text(application.displayName) 20 | .font(.headline) 21 | Text(application.versionNumber.isNotEmpty ? "Version \(application.versionNumber)" : "") 22 | .font(.caption) 23 | Text(application.buildNumber.isNotEmpty ? "Build \(application.buildNumber)" : "") 24 | .font(.caption) 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct AppSummaryView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | AppSummaryView(application: Application.default) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/AppView/NotificationEditorView.strings: -------------------------------------------------------------------------------- 1 | "NotificationView.Hints.APS" = "This JSON is generated according to form values."; 2 | "NotificationView.Hints.UserInfo" = "In this field it is possible to add user defined fields in a key-value format.\n\nExample:\n\"aField\": 2,\n\"anArray\": [1, 2, 3],\n\"aDictionary\": { \"aString\": \"hello\" }\n"; 3 | "NotificationView.Hints.InvalidNotificationJson" = "The JSON is not valid. Please double check the user info field making sure it is in a key-value format without opening and closing braces."; 4 | "NotificationView.Hints.Alert.Title" = "The title of the notification. Apple Watch displays this string in the short look notification interface. Specify a string that is quickly understood by the user."; 5 | "NotificationView.Hints.Alert.Subtitle" = "Additional information that explains the purpose of the notification."; 6 | "NotificationView.Hints.Alert.Body" = "The content of the alert message."; 7 | "NotificationView.Hints.Alert.TitleLocKey" = "The key for a localized title string. Specify this key instead of the title key to retrieve the title from your app’s Localizable.strings files. The value must contain the name of a key in your strings file."; 8 | "NotificationView.Hints.Alert.TitleLocArgs" = "An array of strings separated by comma (,) containing replacement values for variables in your title string. Each %@ character in the string specified by the titleLocalizedKey is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on."; 9 | "NotificationView.Hints.Alert.SubtitleLocKey" = "The key for a localized subtitle string. Use this key, instead of the subtitle key, to retrieve the subtitle from your app’s Localizable.strings file. The value must contain the name of a key in your strings file."; 10 | "NotificationView.Hints.Alert.SubtitleLocArgs" = "An array of strings separated by comma (,) containing replacement values for variables in your subtitle string. Each %@ character in the string specified by subtitle-loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on."; 11 | "NotificationView.Hints.Alert.BodyLocKey" = "The key for a localized message string. Use this key, instead of the body key, to retrieve the message text from your app’s Localizable.strings file. The value must contain the name of a key in your strings file."; 12 | "NotificationView.Hints.Alert.BodyLocArgs" = "An array of strings separated by comma (,) containing replacement values for variables in your message text. Each %@ character in the string specified by loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on."; 13 | "NotificationView.Hints.Sound.Name" = "The name of a sound file in your app’s main bundle or in the Library/Sounds folder of your app’s container directory. Specify the string “default” to play the system sound. For information about how to prepare sounds, see UNNotificationSound."; 14 | "NotificationView.Hints.Sound.Critical" = "The critical alert flag. Set to true to enable the critical alert."; 15 | "NotificationView.Hints.Sound.Volume" = "The volume for the critical alert’s sound. Set this to a value between 0.0 (silent) and 1.0 (full volume)."; 16 | "NotificationView.Hints.Badge" = "The number to display in a badge on your app’s icon. Specify 0 to remove the current badge, if any."; 17 | "NotificationView.Hints.LaunchImage" = "The name of the launch image file to display. If the user chooses to launch your app, the contents of the specified image or storyboard file are displayed instead of your app’s normal launch image."; 18 | "NotificationView.Hints.ThreadIdentifier" = "An app-specific identifier for grouping related notifications. This value corresponds to the threadIdentifier property in the UNNotificationContent object."; 19 | "NotificationView.Hints.Category" = "The notification’s type. This string must correspond to the identifier of one of the UNNotificationCategory objects you register at launch time."; 20 | "NotificationView.Hints.SilentNotification" = "The background notification flag. To perform a silent background update, specify the value true and don’t include the alert, badge, or sound keys in your payload."; 21 | "NotificationView.Hints.MutableContent" = "The notification service app extension flag. If the value is true, the system passes the notification to your notification service app extension before delivery. Use your extension to modify the notification’s content."; 22 | "NotificationView.Hints.TargetContentIdentifier" = "The identifier of the window brought forward. The value of this key will be populated on the UNNotificationContent object created from the push payload. Access the value using the UNNotificationContent object’s targetContentIdentifier property."; 23 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/ColorsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorsView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ColorsView: View { 12 | enum ColorOption { 13 | case hex, swiftUI, uiKit 14 | } 15 | 16 | @State private var pickedColor = PickedColor.default 17 | 18 | @AppStorage("CRColorPickerAccuracy") var colorPickerAccuracy = 2 19 | @AppStorage("CRColorPickerUppercaseHex") var uppercaseHex = true 20 | 21 | @StateObject private var colorHistoryController = ColorHistoryController() 22 | @State private var previouslyPickedSelection: PickedColor.ID? 23 | 24 | var body: some View { 25 | VStack { 26 | Button { 27 | Task { 28 | let selectedColor = await NSColorSampler().sample() 29 | 30 | if let newPickedColor = colorHistoryController.add(selectedColor) { 31 | previouslyPickedSelection = newPickedColor.id 32 | pickedColor = newPickedColor 33 | } 34 | } 35 | } label: { 36 | Label("Select Color", systemImage: "eyedropper") 37 | } 38 | 39 | if let pickedColor { 40 | HStack(spacing: 10) { 41 | Circle() 42 | .fill(pickedColor.swiftUIColor) 43 | .overlay { 44 | Circle() 45 | .strokeBorder(.primary, lineWidth: 1) 46 | } 47 | .frame(width: 50, height: 50) 48 | 49 | Text(pickedColor.hex) 50 | .font(.title) 51 | .textCase(uppercaseHex ? .uppercase : .lowercase) 52 | .textSelection(.enabled) 53 | } 54 | .draggable(assetCatalogData(for: pickedColor)) 55 | .padding(10) 56 | 57 | Form { 58 | LabeledContent("SwiftUI code:") { 59 | Text(pickedColor.swiftUICode(roundedTo: colorPickerAccuracy)) 60 | .font(.body.monospaced()) 61 | .textSelection(.enabled) 62 | } 63 | 64 | LabeledContent("UIKit code:") { 65 | Text(pickedColor.uiKitCode(roundedTo: colorPickerAccuracy)) 66 | .font(.body.monospaced()) 67 | .textSelection(.enabled) 68 | } 69 | .padding(.bottom, 10) 70 | } 71 | } 72 | 73 | Spacer() 74 | .frame(height: 40) 75 | 76 | Text("Previous Colors") 77 | .font(.headline) 78 | 79 | Table(of: PickedColor.self, selection: $previouslyPickedSelection.onChange(updatePickedColor)) { 80 | TableColumn("Color") { color in 81 | Circle() 82 | .fill(color.swiftUIColor) 83 | .overlay { 84 | Circle() 85 | .strokeBorder(.primary, lineWidth: 1) 86 | } 87 | .frame(width: 24, height: 24) 88 | } 89 | .width(40) 90 | 91 | TableColumn("Hex") { color in 92 | Text(color.hex) 93 | .textCase(uppercaseHex ? .uppercase : .lowercase) 94 | } 95 | } rows: { 96 | // We create rows by hand so that we can attach an item 97 | // provider for dragging asset catalog color sets. 98 | ForEach(colorHistoryController.colors) { color in 99 | TableRow(color) 100 | .itemProvider { 101 | let provider = NSItemProvider() 102 | Task { 103 | let catalogData = assetCatalogData(for: color) 104 | provider.register(catalogData) 105 | } 106 | return provider 107 | } 108 | } 109 | } 110 | 111 | HStack { 112 | Menu("Copy") { 113 | Button("Hex String") { 114 | copy(as: .hex) 115 | } 116 | 117 | Button("SwiftUI Code") { 118 | copy(as: .swiftUI) 119 | } 120 | 121 | Button("UIKit Code") { 122 | copy(as: .uiKit) 123 | } 124 | } 125 | .menuIndicator(.hidden) 126 | .fixedSize() 127 | 128 | Button("Delete", action: deletePreviouslySelected) 129 | } 130 | .disabled(previouslyPickedSelection == nil) 131 | 132 | Text("**Tip:** You can drag any of the colors from here directly into an Xcode asset catalog ✨") 133 | .padding(.top, 20) 134 | } 135 | .padding() 136 | .tabItem { 137 | Text("Colors") 138 | } 139 | } 140 | 141 | /// Updates the top area picked color to match a historical picked color 142 | func updatePickedColor() { 143 | pickedColor = colorHistoryController.item(with: previouslyPickedSelection) ?? .default 144 | } 145 | 146 | /// Copies a color option to the clipboard using various available formats. 147 | func copy(as option: ColorOption) { 148 | guard let id = previouslyPickedSelection else { return } 149 | guard let pickedColor = colorHistoryController.item(with: id) else { return } 150 | 151 | let colorString: String 152 | 153 | switch option { 154 | case .hex: 155 | if uppercaseHex { 156 | colorString = pickedColor.hex.uppercased() 157 | } else { 158 | colorString = pickedColor.hex.lowercased() 159 | } 160 | case .swiftUI: 161 | colorString = pickedColor.swiftUICode(roundedTo: colorPickerAccuracy) 162 | case .uiKit: 163 | colorString = pickedColor.uiKitCode(roundedTo: colorPickerAccuracy) 164 | } 165 | 166 | NSPasteboard.general.clearContents() 167 | NSPasteboard.general.setString(colorString, forType: .string) 168 | } 169 | 170 | func deletePreviouslySelected() { 171 | colorHistoryController.delete(previouslyPickedSelection) 172 | previouslyPickedSelection = nil 173 | } 174 | 175 | func assetCatalogData(for color: PickedColor) -> URL { 176 | let saveDirectory = URL.temporaryDirectory.appending(path: "New Color.colorset") 177 | try? FileManager.default.createDirectory(at: saveDirectory, withIntermediateDirectories: true) 178 | 179 | let colorSet = XcodeColorSet(red: color.hexRed, green: color.hexGreen, blue: color.hexBlue) 180 | 181 | let contentsURL = saveDirectory.appending(path: "Contents.json") 182 | 183 | let encoder = JSONEncoder() 184 | encoder.outputFormatting = .prettyPrinted 185 | 186 | let encodedData = try? encoder.encode(colorSet) 187 | try? encodedData?.write(to: contentsURL) 188 | 189 | return saveDirectory 190 | } 191 | } 192 | 193 | struct ColorsView_Previews: PreviewProvider { 194 | static var previews: some View { 195 | ColorsView() 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocalSearchRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalSearchRowView.swift 3 | // ControlRoom 4 | // 5 | // Created by John McEvoy on 29/11/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import CoreLocation 11 | 12 | struct LocalSearchRowView: View { 13 | @Binding var lastHoverId: UUID? 14 | @State private var isHovered = false 15 | let result: LocalSearchResult 16 | let onTap: () -> Void 17 | 18 | var body: some View { 19 | Button { 20 | onTap() 21 | } label: { 22 | HStack { 23 | 24 | Image(systemName: "mappin.circle.fill") 25 | .symbolRenderingMode(.multicolor) 26 | .font(.system(size: 24)) 27 | 28 | VStack(alignment: .leading, spacing: 2) { 29 | Text(result.title) 30 | .font(.body) 31 | .foregroundColor(.primary) 32 | .lineLimit(1) 33 | 34 | if let subtitle = result.subtitle { 35 | Text(subtitle) 36 | .font(.caption) 37 | .foregroundColor(.secondary) 38 | .lineLimit(1) 39 | } 40 | } 41 | Spacer() 42 | } 43 | } 44 | .buttonStyle(.borderless) 45 | .frame(minHeight: 36) 46 | .padding(.horizontal, 8) 47 | .padding(.vertical, 4) 48 | .background(isHovered ? .blue : .clear) 49 | .cornerRadius(8) 50 | .onChange(of: lastHoverId) { 51 | isHovered = $0 == result.id 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/OverridesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OverridesView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 07/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct OverridesView: View { 12 | let simulator: Simulator 13 | 14 | /// The system-wide appearance; "Light" or "Dark". 15 | @State private var appearance: SimCtl.UI.Appearance = .light 16 | 17 | /// The currently active language identifier 18 | @State private var language: String = NSLocale.current.language.languageCode?.identifier ?? "" 19 | 20 | /// The currently active locale identifier 21 | @State private var locale: String = NSLocale.current.identifier 22 | 23 | // The current Dynamic Type sizes 24 | @State private var contentSize: SimCtl.UI.ContentSizes = .medium 25 | 26 | @State private var enhanceTextLegibility = false 27 | @State private var showButtonShapes = false 28 | @State private var showOnOffLabels = false 29 | @State private var reduceTransparency = false 30 | @State private var increaseContrast = false 31 | @State private var differentiateWithoutColor = false 32 | @State private var smartInvert = false 33 | 34 | @State private var reduceMotion = false 35 | @State private var preferCrossFadeTransitions = false 36 | 37 | private let languages: [String] = { 38 | NSLocale.isoLanguageCodes 39 | .filter { NSLocale.current.localizedString(forLanguageCode: $0) != nil } 40 | .sorted { lhs, rhs in 41 | let lhsString = NSLocale.current.localizedString(forLanguageCode: lhs) ?? "" 42 | let rhsString = NSLocale.current.localizedString(forLanguageCode: rhs) ?? "" 43 | return lhsString.lowercased() < rhsString.lowercased() 44 | } 45 | }() 46 | 47 | var body: some View { 48 | ScrollView { 49 | Form { 50 | Group { 51 | Picker("Appearance:", selection: $appearance.onChange(updateAppearance)) { 52 | ForEach(SimCtl.UI.Appearance.allCases, id: \.self) { 53 | Text($0.displayName) 54 | } 55 | } 56 | } 57 | 58 | Spacer() 59 | .frame(height: 40) 60 | 61 | Group { 62 | Picker("Language:", selection: $language) { 63 | ForEach(languages, id: \.self) { 64 | Text(NSLocale.current.localizedString(forLanguageCode: $0) ?? "") 65 | } 66 | } 67 | Picker("Locale:", selection: $locale) { 68 | ForEach(locales(for: language), id: \.self) { 69 | Text(NSLocale.current.localizedString(forIdentifier: $0) ?? "") 70 | } 71 | } 72 | HStack { 73 | Button("Set Language/Locale", action: updateLanguage) 74 | Text("(Requires Reboot)").font(.system(size: 11)).foregroundColor(.secondary) 75 | } 76 | } 77 | 78 | Spacer() 79 | .frame(height: 40) 80 | 81 | Section(header: 82 | Text("Accessibility overrides") 83 | .font(.headline) 84 | ) { 85 | Picker("Content size:", selection: $contentSize) { 86 | ForEach(SimCtl.UI.ContentSizes.allCases, id: \.self) { size in 87 | HStack { 88 | Text(size.rawValue) 89 | } 90 | } 91 | } 92 | .onChange(of: contentSize) { _ in 93 | updateContentSize() 94 | } 95 | 96 | Toggle("Bold Text", isOn: $enhanceTextLegibility.onChange(setEnhanceTextLegibility)) 97 | Toggle("Button Shapes", isOn: $showButtonShapes.onChange(setShowButtonShapes)) 98 | Toggle("On/Off Labels", isOn: $showOnOffLabels.onChange(setShowOnOffLabels)) 99 | Toggle("Reduce Transparency", isOn: $reduceTransparency.onChange(setReduceTransparency)) 100 | Toggle("Increase Contrast", isOn: $increaseContrast.onChange(setIncreaseContrast)) 101 | Toggle("Differentiate Without Color", isOn: $differentiateWithoutColor.onChange(setDifferentiateWithoutColor)) 102 | Toggle("Smart Invert", isOn: $smartInvert.onChange(setSmartInvert)) 103 | } 104 | 105 | Toggle("Reduce Motion", isOn: $reduceMotion.onChange(setReduceMotion)) 106 | 107 | Toggle("Prefer Cross-Fade Transitions", isOn: $preferCrossFadeTransitions.onChange(setPreferCrossFadeTransitions)) 108 | .disabled(reduceMotion == false) 109 | } 110 | .padding() 111 | } 112 | .tabItem { 113 | Text("Overrides") 114 | } 115 | } 116 | 117 | /// Moves between light and dark mode. 118 | func updateAppearance() { 119 | SimCtl.setAppearance(simulator.udid, appearance: appearance) 120 | } 121 | 122 | func updateLanguage() { 123 | let plistPath = simulator.dataPath + "/Library/Preferences/.GlobalPreferences.plist" 124 | _ = Process.execute("/usr/bin/xcrun", arguments: ["plutil", "-replace", "AppleLanguages", "-json", "[\"\(language)\" ]", plistPath]) 125 | _ = Process.execute("/usr/bin/xcrun", arguments: ["plutil", "-replace", "AppleLocale", "-string", locale, plistPath]) 126 | SimCtl.reboot(simulator) 127 | } 128 | 129 | private func locales(for language: String) -> [String] { 130 | NSLocale.availableLocaleIdentifiers 131 | .filter { $0.hasPrefix(language) } 132 | .sorted { (lhs, rhs) -> Bool in 133 | let lhsString = NSLocale.current.localizedString(forIdentifier: lhs) ?? "" 134 | let rhsString = NSLocale.current.localizedString(forIdentifier: rhs) ?? "" 135 | return lhsString.lowercased() < rhsString.lowercased() 136 | } 137 | } 138 | 139 | /// Update Content Size. 140 | func updateContentSize() { 141 | SimCtl.setContentSize(simulator.udid, contentSize: contentSize) 142 | } 143 | 144 | // Updates the simulator's accessibility setting for a particular key. 145 | // Example call: xcrun simctl spawn booted defaults write com.apple.Accessibility EnhancedTextLegibilityEnabled -bool FALSE 146 | func updateAccessibility(key: String, value: Bool) { 147 | _ = Process.execute("/usr/bin/xcrun", arguments: ["simctl", "spawn", simulator.id, "defaults", "write", "com.apple.Accessibility", key, "-bool", String(value)]) 148 | } 149 | 150 | func setEnhanceTextLegibility() { 151 | updateAccessibility(key: "EnhancedTextLegibilityEnabled", value: enhanceTextLegibility) 152 | } 153 | 154 | func setShowButtonShapes() { 155 | updateAccessibility(key: "ButtonShapesEnabled", value: showButtonShapes) 156 | } 157 | 158 | func setShowOnOffLabels() { 159 | updateAccessibility(key: "IncreaseButtonLegibilityEnabled", value: showOnOffLabels) 160 | } 161 | 162 | func setReduceTransparency() { 163 | updateAccessibility(key: "EnhancedBackgroundContrastEnabled", value: reduceTransparency) 164 | } 165 | 166 | func setIncreaseContrast() { 167 | updateAccessibility(key: "DarkenSystemColors", value: increaseContrast) 168 | } 169 | 170 | func setDifferentiateWithoutColor() { 171 | updateAccessibility(key: "DifferentiateWithoutColor", value: differentiateWithoutColor) 172 | } 173 | 174 | func setSmartInvert() { 175 | updateAccessibility(key: "InvertColorsEnabled", value: smartInvert) 176 | } 177 | 178 | func setReduceMotion() { 179 | updateAccessibility(key: "ReduceMotionEnabled", value: reduceMotion) 180 | 181 | // Automatically disable the cross-fade animation if reduce motion is being 182 | // disabled. This matches what Settings does. 183 | if reduceMotion == false { 184 | preferCrossFadeTransitions = false 185 | updateAccessibility(key: "ReduceMotionReduceSlideTransitionsPreference", value: false) 186 | } 187 | } 188 | 189 | func setPreferCrossFadeTransitions() { 190 | updateAccessibility(key: "ReduceMotionReduceSlideTransitionsPreference", value: preferCrossFadeTransitions) 191 | } 192 | } 193 | 194 | struct OverridesView_Previews: PreviewProvider { 195 | static var previews: some View { 196 | OverridesView(simulator: .example) 197 | .environmentObject(Preferences()) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/SnapshotAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapshotAction.swift 3 | // ControlRoom 4 | // 5 | // Created by Marcel Mendes on 14/12/24. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import struct SwiftUI.LocalizedStringKey 10 | 11 | enum SnapshotAction: Int, Identifiable { 12 | case delete 13 | case rename 14 | case restore 15 | 16 | var id: Int { rawValue } 17 | 18 | var sheetTitle: LocalizedStringKey { 19 | switch self { 20 | case .delete: "Delete Snapshot" 21 | case .rename: "Rename Snapshot" 22 | case .restore: "Restore Snapshot" 23 | } 24 | } 25 | 26 | var sheetMessage: LocalizedStringKey { 27 | switch self { 28 | case .delete: "Are you sure you want to delete this snapshot? You will not be able to undo this action." 29 | case .rename: "Enter a new name for this snapshot. It must be unique." 30 | case .restore: "Are you sure you want to restore this snapshot? You will not be able to undo this action." 31 | } 32 | } 33 | 34 | var saveActionTitle: LocalizedStringKey { 35 | switch self { 36 | case .delete: "Delete" 37 | case .rename: "Rename" 38 | case .restore: "Restore" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/SnapshotsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapshotsView.swift 3 | // ControlRoom 4 | // 5 | // Created by Marcel Mendes on 14/12/24. 6 | // Copyright © 2024 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SnapshotsView: View { 12 | let simulator: Simulator 13 | @ObservedObject var controller: SimulatorsController 14 | 15 | @State private var snapshotAction: SnapshotAction? 16 | @State private var newName: String 17 | @State private var selectedSnapshotName: String 18 | 19 | init(simulator: Simulator, controller: SimulatorsController) { 20 | self.simulator = simulator 21 | self.controller = controller 22 | self._newName = State(initialValue: simulator.name) 23 | self._selectedSnapshotName = State(initialValue: simulator.name) 24 | } 25 | 26 | private let formatter = MeasurementFormatter() 27 | 28 | var body: some View { 29 | ScrollView { 30 | if controller.snapshots.count > 0 { 31 | Form { 32 | Section { 33 | LabeledContent("Snapshots:") { 34 | VStack(alignment: .leading, spacing: 5) { 35 | ForEach(controller.snapshots.sorted(by: { $0.creationDate > $1.creationDate }), id: \.id) { snapshot in 36 | 37 | let folderSize = Measurement(value: Double(snapshot.size), unit: UnitInformationStorage.bytes) 38 | 39 | HStack { 40 | Button { 41 | restore(snapshot: snapshot.id) 42 | } label: { 43 | Label("Restore", systemImage: "arrow.counterclockwise") 44 | } 45 | 46 | Button { 47 | rename(snapshot: snapshot.id) 48 | } label: { 49 | Label("Rename", systemImage: "pencil") 50 | } 51 | 52 | Text(snapshot.id) 53 | .fontWeight(.semibold) 54 | 55 | Group { 56 | Text(snapshot.creationDate.formatted(date: .numeric, time: .standard)) 57 | Text(formatter.string(from: folderSize.converted(to: .gigabytes))) 58 | } 59 | .font(.callout) 60 | .fontWeight(.thin) 61 | 62 | Button { 63 | delete(snapshot: snapshot.id) 64 | } label: { 65 | Label("Delete", systemImage: "trash") 66 | } 67 | 68 | Spacer() 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | .padding() 76 | } else { 77 | VStack(spacing: 10) { 78 | Spacer() 79 | Image(systemName: simulator.deviceFamily.snapshotUnavailableIcon) 80 | Text("No snapshots yet") 81 | } 82 | .font(.title) 83 | } 84 | } 85 | .tabItem { 86 | Text("Snapshots") 87 | } 88 | .sheet(item: $snapshotAction) { action in 89 | switch action { 90 | case .rename: 91 | SimulatorActionSheet( 92 | icon: simulator.image, 93 | message: action.sheetTitle, 94 | informativeText: action.sheetMessage, 95 | confirmationTitle: action.saveActionTitle, 96 | confirm: { performAction(action) }, 97 | canConfirm: newName.isNotEmpty, 98 | content: { 99 | TextField("Name", text: $newName) 100 | } 101 | ) 102 | case .delete, .restore: 103 | SimulatorActionSheet( 104 | icon: simulator.image, 105 | message: action.sheetTitle, 106 | informativeText: action.sheetMessage, 107 | confirmationTitle: action.saveActionTitle, 108 | confirm: { performAction(action) }) 109 | } 110 | } 111 | 112 | } 113 | 114 | private func rename(snapshot: String) { 115 | selectedSnapshotName = snapshot 116 | newName = snapshot 117 | snapshotAction = .rename 118 | } 119 | 120 | private func delete(snapshot: String) { 121 | selectedSnapshotName = snapshot 122 | snapshotAction = .delete 123 | } 124 | 125 | private func restore(snapshot: String) { 126 | selectedSnapshotName = snapshot 127 | snapshotAction = .restore 128 | } 129 | 130 | private func performAction(_ action: SnapshotAction) { 131 | switch action { 132 | case .delete: SnapshotCtl.deleteSnapshot(deviceId: simulator.udid, snapshotName: selectedSnapshotName) 133 | case .rename: SnapshotCtl.renameSnapshot(deviceId: simulator.udid, snapshotName: selectedSnapshotName, newSnapshotName: newName) 134 | case .restore: SnapshotCtl.restoreSnapshot(deviceId: simulator.udid, snapshotName: selectedSnapshotName) 135 | } 136 | } 137 | 138 | func placeholder() {} 139 | 140 | } 141 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlScreens/SystemView/DeepLinkEditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkEditorView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 16/05/2023. 6 | // Copyright © 2023 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DeepLinkEditorView: View { 12 | @EnvironmentObject var deepLinks: DeepLinksController 13 | @Environment(\.dismiss) var dismiss 14 | 15 | /// The link name the user is currently adding. 16 | @State private var newLinkName = "" 17 | 18 | /// The link URL the user is currently adding. 19 | @State private var newLinkURL = "" 20 | 21 | /// The order we're displaying our links, defaulting to name. 22 | @State private var sortOrder = [KeyPathComparator(\DeepLink.name)] 23 | 24 | /// The currently selected deep link, or nil if nothing is selected. 25 | @State private var selection: DeepLink.ID? 26 | 27 | /// Whether we are currently showing the alert to let the user add a new deep link. 28 | @State private var showingAddAlert = false 29 | 30 | /// Whether we are currently showing the sheet to let the user edit an existing deep link. 31 | @State private var showingEditSheet = false 32 | 33 | var body: some View { 34 | VStack { 35 | Text("Saved Deep Links") 36 | .font(.title) 37 | 38 | Text("Create named deep links or other URLs to make them easier to open repeatedly inside Control Room. **Tip:** Adjusting the sort order adjusts the order here, in the System tab, and in the menu bar list.") 39 | 40 | if deepLinks.links.isEmpty { 41 | Spacer() 42 | Text("No saved deep links created yet.") 43 | Spacer() 44 | } else { 45 | Table(deepLinks.links, selection: $selection, sortOrder: $sortOrder) { 46 | TableColumn("Name", value: \.name) 47 | TableColumn("URL", value: \.url.absoluteString) 48 | } 49 | .contextMenu(forSelectionType: DeepLink.ID.self) { _ in 50 | 51 | } primaryAction: { _ in 52 | showingEditSheet.toggle() 53 | } 54 | } 55 | 56 | HStack { 57 | Button("Add New") { 58 | showingAddAlert.toggle() 59 | } 60 | 61 | Button("Edit") { 62 | showingEditSheet.toggle() 63 | } 64 | .disabled(selection == nil) 65 | 66 | Button("Duplicate") { 67 | duplicateSelected() 68 | } 69 | .disabled(selection == nil) 70 | 71 | Button("Delete") { 72 | deleteSelected() 73 | } 74 | .disabled(selection == nil) 75 | 76 | Spacer() 77 | Button("Done") { dismiss() } 78 | } 79 | } 80 | .frame(width: 500) 81 | .frame(minHeight: 350) 82 | .padding() 83 | .alert("Add new deep link", isPresented: $showingAddAlert) { 84 | TextField("Name", text: $newLinkName) 85 | TextField("URL", text: $newLinkURL) 86 | Button("Add", action: addLink) 87 | Button("Cancel", role: .cancel) { } 88 | } message: { 89 | Text("Make sure you include a schema, e.g. https:// or yourapp://") 90 | } 91 | .sheet(isPresented: $showingEditSheet, content: { 92 | EditDeepLinkView(deepLink: $selection) 93 | }) 94 | .onChange(of: sortOrder) { newOrder in 95 | deepLinks.sort(using: newOrder) 96 | } 97 | } 98 | 99 | /// Triggered by our alert, when the user wants to save their new deep link. 100 | func addLink() { 101 | deepLinks.create(name: newLinkName, url: newLinkURL) 102 | newLinkName = "" 103 | newLinkURL = "" 104 | } 105 | 106 | /// Deletes whatever is the currently selected deep link. 107 | func deleteSelected() { 108 | deepLinks.delete(selection) 109 | selection = nil 110 | } 111 | 112 | func duplicateSelected() { 113 | if let link = deepLinks.link(selection) { 114 | deepLinks.create(name: link.name + " (copy)", url: link.url.absoluteString) 115 | } 116 | } 117 | } 118 | 119 | private extension DeepLinkEditorView { 120 | struct EditDeepLinkView: View { 121 | @EnvironmentObject private var deepLinks: DeepLinksController 122 | @Environment(\.dismiss) private var dismiss 123 | 124 | @Binding var deepLink: DeepLink.ID? 125 | @State private var name: String = "" 126 | @State private var url: String = "" 127 | 128 | var body: some View { 129 | VStack { 130 | Text("Edit Deep Link") 131 | .font(.title) 132 | 133 | TextField("Name", text: $name) 134 | TextField("URL", text: $url) 135 | 136 | HStack { 137 | Spacer() 138 | 139 | Button("Cancel", role: .cancel) { 140 | dismiss() 141 | } 142 | .focusable() 143 | .keyboardShortcut(.escape) 144 | 145 | Button("Save") { 146 | deepLinks.edit(deepLink, name: name, url: url) 147 | dismiss() 148 | } 149 | .focusable() 150 | .keyboardShortcut(.return) 151 | } 152 | } 153 | .onAppear { 154 | if let link = deepLinks.link(deepLink) { 155 | self.name = link.name 156 | self.url = link.url.absoluteString 157 | } 158 | } 159 | .padding() 160 | } 161 | } 162 | } 163 | 164 | struct DeepLinkEditorView_Previews: PreviewProvider { 165 | static var previews: some View { 166 | DeepLinkEditorView() 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /ControlRoom/Simulator UI/ControlView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControlView.swift 3 | // ControlRoom 4 | // 5 | // Created by Paul Hudson on 12/02/2020. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// The main tab view to control simulator settings. 12 | struct ControlView: View { 13 | /// Used to handle creating screenshots, videos, and GIFs. 14 | @StateObject private var captureController = CaptureController() 15 | 16 | /// Let's us watch the list of active simulators. 17 | @ObservedObject var controller: SimulatorsController 18 | 19 | let simulator: Simulator 20 | let applications: [Application] 21 | 22 | var body: some View { 23 | TabView { 24 | SystemView(simulator: simulator) 25 | .disabled(simulator.state != .booted) 26 | SnapshotsView(simulator: simulator, controller: controller) 27 | Group { 28 | AppView(simulator: simulator, applications: applications) 29 | LocationView(controller: controller, simulator: simulator) 30 | StatusBarView(simulator: simulator) 31 | OverridesView(simulator: simulator) 32 | ColorsView() 33 | } 34 | .disabled(simulator.state != .booted) 35 | 36 | } 37 | .navigationSubtitle("\(simulator.name) – \(simulator.runtime?.name ?? "Unknown OS")") 38 | .toolbar { 39 | Menu("Save \(captureController.imageFormatString)") { 40 | Button("Save as PNG") { 41 | captureController.takeScreenshot(of: simulator, format: .png) 42 | } 43 | 44 | Button("Save as JPEG") { 45 | captureController.takeScreenshot(of: simulator, format: .jpeg) 46 | } 47 | 48 | Button("Save as TIFF") { 49 | captureController.takeScreenshot(of: simulator, format: .tiff) 50 | } 51 | 52 | Button("Save as BMP") { 53 | captureController.takeScreenshot(of: simulator, format: .bmp) 54 | } 55 | } primaryAction: { 56 | captureController.takeScreenshot(of: simulator) 57 | } 58 | 59 | if captureController.recordingProcess == nil { 60 | Menu("Record \(captureController.videoFormatString)") { 61 | ForEach(SimCtl.IO.VideoFormat.all, id: \.self) { item in 62 | if item == .divider { 63 | Divider() 64 | } else { 65 | Button("Save as \(item.name)") { 66 | captureController.startRecordingVideo(of: simulator, format: item) 67 | } 68 | } 69 | } 70 | } primaryAction: { 71 | captureController.startRecordingVideo(of: simulator) 72 | } 73 | } else { 74 | Button("Stop Recording", action: captureController.stopRecordingVideo) 75 | } 76 | 77 | if simulator.state != .booted { 78 | Button("Boot", action: bootDevice) 79 | } 80 | 81 | if simulator.state != .shutdown { 82 | Button("Shutdown", action: shutdownDevice) 83 | } 84 | } 85 | } 86 | 87 | /// Launches the current device. 88 | func bootDevice() { 89 | SimCtl.boot(simulator) 90 | } 91 | 92 | /// Terminates the current device. 93 | func shutdownDevice() { 94 | SimCtl.shutdown(simulator.udid) 95 | } 96 | } 97 | 98 | struct ControlView_Previews: PreviewProvider { 99 | static var previews: some View { 100 | ControlView(controller: .init(preferences: .init()), 101 | simulator: .example, 102 | applications: []) 103 | .environmentObject(Preferences()) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /ControlRoom/ar.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | 2 | /* Class = "NSMenuItem"; title = "ControlRoom"; ObjectID = "1Xt-HY-uBw"; */ 3 | "1Xt-HY-uBw.title" = "ControlRoom"; 4 | 5 | /* Class = "NSMenuItem"; title = "Delete Selected Simulators..."; ObjectID = "23s-gN-NCt"; */ 6 | "23s-gN-NCt.title" = "Delete Selected Simulators..."; 7 | 8 | /* Class = "NSMenuItem"; title = "Enter Full Screen"; ObjectID = "4J7-dP-txa"; */ 9 | "4J7-dP-txa.title" = "Enter Full Screen"; 10 | 11 | /* Class = "NSMenuItem"; title = "Quit ControlRoom"; ObjectID = "4sb-4s-VLi"; */ 12 | "4sb-4s-VLi.title" = "Quit ControlRoom"; 13 | 14 | /* Class = "NSMenuItem"; title = "Edit"; ObjectID = "5QF-Oa-p0T"; */ 15 | "5QF-Oa-p0T.title" = "Edit"; 16 | 17 | /* Class = "NSMenuItem"; title = "About Control Room"; ObjectID = "5kV-Vb-QxS"; */ 18 | "5kV-Vb-QxS.title" = "About Control Room"; 19 | 20 | /* Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6"; */ 21 | "AYu-sK-qS6.title" = "Main Menu"; 22 | 23 | /* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */ 24 | "BOF-NM-1cW.title" = "Preferences…"; 25 | 26 | /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */ 27 | "DVo-aG-piG.title" = "Close"; 28 | 29 | /* Class = "NSMenu"; title = "Help"; ObjectID = "F2S-fz-NVQ"; */ 30 | "F2S-fz-NVQ.title" = "Help"; 31 | 32 | /* Class = "NSMenuItem"; title = "Project GitHub"; ObjectID = "FKE-Sm-Kum"; */ 33 | "FKE-Sm-Kum.title" = "Project GitHub"; 34 | 35 | /* Class = "NSMenuItem"; title = "View"; ObjectID = "H8h-7b-M4v"; */ 36 | "H8h-7b-M4v.title" = "View"; 37 | 38 | /* Class = "NSMenu"; title = "View"; ObjectID = "HyV-fh-RgO"; */ 39 | "HyV-fh-RgO.title" = "View"; 40 | 41 | /* Class = "NSMenuItem"; title = "Undo"; ObjectID = "IA0-pV-GaV"; */ 42 | "IA0-pV-GaV.title" = "Undo"; 43 | 44 | /* Class = "NSMenuItem"; title = "Show All"; ObjectID = "Kd2-mp-pUS"; */ 45 | "Kd2-mp-pUS.title" = "Show All"; 46 | 47 | /* Class = "NSMenuItem"; title = "Bring All to Front"; ObjectID = "LE2-aR-0XJ"; */ 48 | "LE2-aR-0XJ.title" = "Bring All to Front"; 49 | 50 | /* Class = "NSMenuItem"; title = "Services"; ObjectID = "NMo-om-nkz"; */ 51 | "NMo-om-nkz.title" = "Services"; 52 | 53 | /* Class = "NSMenuItem"; title = "Minimize"; ObjectID = "OY7-WF-poV"; */ 54 | "OY7-WF-poV.title" = "Minimize"; 55 | 56 | /* Class = "NSMenuItem"; title = "Hide ControlRoom"; ObjectID = "Olw-nP-bQN"; */ 57 | "Olw-nP-bQN.title" = "Hide ControlRoom"; 58 | 59 | /* Class = "NSMenuItem"; title = "Zoom"; ObjectID = "R4o-n2-Eq4"; */ 60 | "R4o-n2-Eq4.title" = "Zoom"; 61 | 62 | /* Class = "NSMenuItem"; title = "Select All"; ObjectID = "Ruw-6m-B2m"; */ 63 | "Ruw-6m-B2m.title" = "Select All"; 64 | 65 | /* Class = "NSMenu"; title = "Window"; ObjectID = "Td7-aD-5lo"; */ 66 | "Td7-aD-5lo.title" = "Window"; 67 | 68 | /* Class = "NSMenuItem"; title = "Stay In Front"; ObjectID = "Tzd-5z-P3W"; */ 69 | "Tzd-5z-P3W.title" = "Stay In Front"; 70 | 71 | /* Class = "NSMenuItem"; title = "Hide Others"; ObjectID = "Vdr-fp-XzO"; */ 72 | "Vdr-fp-XzO.title" = "Hide Others"; 73 | 74 | /* Class = "NSMenu"; title = "Edit"; ObjectID = "W48-6f-4Dl"; */ 75 | "W48-6f-4Dl.title" = "Edit"; 76 | 77 | /* Class = "NSMenuItem"; title = "New Simulator..."; ObjectID = "WUk-cr-sBl"; */ 78 | "WUk-cr-sBl.title" = "New Simulator..."; 79 | 80 | /* Class = "NSMenuItem"; title = "Window"; ObjectID = "aUF-d1-5bR"; */ 81 | "aUF-d1-5bR.title" = "Window"; 82 | 83 | /* Class = "NSMenu"; title = "File"; ObjectID = "bib-Uj-vzu"; */ 84 | "bib-Uj-vzu.title" = "File"; 85 | 86 | /* Class = "NSMenuItem"; title = "Copy"; ObjectID = "crV-ox-do9"; */ 87 | "crV-ox-do9.title" = "Copy"; 88 | 89 | /* Class = "NSMenuItem"; title = "File"; ObjectID = "dMs-cI-mzQ"; */ 90 | "dMs-cI-mzQ.title" = "File"; 91 | 92 | /* Class = "NSMenuItem"; title = "Paste"; ObjectID = "f5u-Np-Ehc"; */ 93 | "f5u-Np-Ehc.title" = "Paste"; 94 | 95 | /* Class = "NSMenuItem"; title = "Cut"; ObjectID = "fuj-su-hDM"; */ 96 | "fuj-su-hDM.title" = "Cut"; 97 | 98 | /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ 99 | "hz9-B4-Xy5.title" = "Services"; 100 | 101 | /* Class = "NSMenuItem"; title = "Show Sidebar"; ObjectID = "kIP-vf-haE"; */ 102 | "kIP-vf-haE.title" = "Show Sidebar"; 103 | 104 | /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "pSm-M9-WHH"; */ 105 | "pSm-M9-WHH.title" = "Redo"; 106 | 107 | /* Class = "NSMenuItem"; title = "Delete Unavailable Simulators..."; ObjectID = "tVk-RZ-EV2"; */ 108 | "tVk-RZ-EV2.title" = "Delete Unavailable Simulators..."; 109 | 110 | /* Class = "NSMenu"; title = "ControlRoom"; ObjectID = "uQy-DD-JDr"; */ 111 | "uQy-DD-JDr.title" = "ControlRoom"; 112 | 113 | /* Class = "NSMenuItem"; title = "Help"; ObjectID = "wpr-3q-Mcd"; */ 114 | "wpr-3q-Mcd.title" = "Help"; 115 | -------------------------------------------------------------------------------- /ControlRoomTests/Controllers/SimCtl+SubCommandsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControlRoomTests.swift 3 | // ControlRoomTests 4 | // 5 | // Created by Patrick Luddy on 2/16/20. 6 | // Copyright © 2020 Paul Hudson. All rights reserved. 7 | // 8 | 9 | @testable import Control_Room 10 | import XCTest 11 | 12 | class SimCtlSubCommandsTests: XCTestCase { 13 | 14 | func testDeleteUnavailable() throws { 15 | let command: SimCtl.Command = .delete(.unavailable) 16 | let expectation = ["simctl", "delete", "unavailable"] 17 | XCTAssertEqual(command.arguments, expectation) 18 | } 19 | 20 | func testBoot() throws { 21 | let getSimulator: (String) -> Simulator = { buildVersion in 22 | let runtime = Runtime(buildversion: buildVersion, identifier: "made-up", version: "version", isAvailable: true, name: "iPhone 14") 23 | return Simulator(name: "iPhone 14", udid: "made-up-udid", state: .shutdown, runtime: runtime, deviceType: nil, dataPath: "fake-path") 24 | } 25 | let expectedArguments = ["simctl", "boot", "made-up-udid"] 26 | 27 | let command160: SimCtl.Command = .boot(simulator: getSimulator("16.0")) 28 | XCTAssertEqual(command160.arguments, expectedArguments) 29 | XCTAssertEqual(command160.environmentOverrides, nil) 30 | 31 | let command161: SimCtl.Command = .boot(simulator: getSimulator("16.1")) 32 | XCTAssertEqual(command161.arguments, expectedArguments) 33 | XCTAssertEqual(command161.environmentOverrides, ["SIMCTL_CHILD_SIMULATOR_RUNTIME_VERSION": "16.0"]) 34 | } 35 | 36 | func testRecordAVideo() throws { 37 | let command: SimCtl.Command = .io(deviceId: "device1", operation: .recordVideo(codec: .h264, url: "~/my-video.mov")) 38 | let expectation = ["simctl", "io", "device1", "recordVideo", "--codec=h264", "~/my-video.mov"] 39 | XCTAssertEqual(command.arguments, expectation) 40 | } 41 | 42 | func testScreenshot() throws { 43 | let command: SimCtl.Command = .io(deviceId: "device1", operation: .screenshot(type: .png, display: .internal, mask: .ignored, url: "~/my-image.png")) 44 | let expectation = ["simctl", "io", "device1", "screenshot", "--type=png", "--display=internal", "--mask=ignored", "~/my-image.png"] 45 | XCTAssertEqual(command.arguments, expectation) 46 | } 47 | 48 | func testlist() throws { 49 | let command: SimCtl.Command = .list() 50 | let expectation = ["simctl", "list"] 51 | XCTAssertEqual(command.arguments, expectation) 52 | } 53 | 54 | func testlistFilterSearchFlag() throws { 55 | let command: SimCtl.Command = .list(filter: .devicetypes, search: .string("search"), flags: [.json]) 56 | let expectation = ["simctl", "list", "devicetypes", "search", "-j"] 57 | XCTAssertEqual(command.arguments, expectation) 58 | } 59 | 60 | func testOpenUrl() throws { 61 | let command: SimCtl.Command = .openURL(deviceId: "device1", url: "https://www.hackingwithswift.com") 62 | let expectation = ["simctl", "openurl", "device1", "https://www.hackingwithswift.com"] 63 | XCTAssertEqual(command.arguments, expectation) 64 | } 65 | 66 | func testAddMedia() throws { 67 | let command: SimCtl.Command = .addMedia(deviceId: "device1", mediaPaths: ["~/sample-1.jpg"]) 68 | let expectation = ["simctl", "addmedia", "device1", "~/sample-1.jpg"] 69 | XCTAssertEqual(command.arguments, expectation) 70 | } 71 | 72 | func testDefaultsForApp() throws { 73 | let command: SimCtl.Command = .spawn(deviceId: "device1", pathToExecutable: "defaults read", options: [.waitForDebugger]) 74 | let expectation = ["simctl", "spawn", "-w", "device1", "defaults read"] 75 | XCTAssertEqual(command.arguments, expectation) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /ControlRoomTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paul Hudson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Control Room logo 3 |

4 | 5 |

6 | 7 | 8 | 9 | Twitter: @twostraws 10 | 11 |

12 | 13 | Control Room is a macOS app that lets you control the simulators for iOS, tvOS, and watchOS – their UI appearance, status bar configuration, and more. It wraps Apple’s own **simctl** command-line tool, so you’ll need Xcode installed. 14 | 15 | You’ll need Xcode 14.0 or later to build and use Control Room on your Mac. 16 | 17 | 18 | ## Installation 19 | 20 | To try Control Room yourself, download the code and build it through Xcode. It’s built using SwiftUI, so you’ll need macOS Big Sur in order to run it. You will also need Xcode installed, because it relies on the **simctl** command being present – if you see an error that you’re missing the command line tools, go to Xcode's Preferences, choose the Locations tab, then make sure Xcode is selected for Command Line Tools. 21 | 22 | 23 | ## Features 24 | 25 | Control Room is packed with features to help you develop apps more effectively, including: 26 | 27 | - Taking screenshots and movies, optionally adding the device bezels to your screenshots. 28 | - Adjusting the system time and date to whatever you want, including Apple’s preferred 9:41. 29 | - Controlling status of WiFi, cellular service, and battery. 30 | - Opening the data folder for your app, or editing your `UserDefaults` entries. 31 | - Overriding dark or light mode, language, accessibility options, and Dynamic Type content size. 32 | - Picking a custom user location from anywhere in the world. 33 | - Starting, stopping, installing, and removing apps. 34 | - Sending test push notifications or triggering deep links. 35 | - Selecting colors from the simulator, converting them to UIKit or SwiftUI code, or even dragging directly into your asset catalog. 36 | 37 | Plus there’s an optional menu bar icon adding quick actions such as re-sending the last push notification or re-opening your last deep link. 38 | 39 | 40 | 41 | ## Contribution guide 42 | 43 | Any help you can offer with this project is most welcome – there are opportunities big and small so that someone with only a small amount of Swift experience can help. 44 | 45 | Some suggestions you might want to explore: 46 | 47 | - Handle errors in a meaningful way. 48 | - Add documentation in the code or here in the README. 49 | - Did I mention handling errors in a meaningful way? 50 | 51 | You’re also welcome to try adding some tests, although given our underlying use of simctl that might be tricky. 52 | 53 | If you spot any errors please open an issue and let us know which macOS and Xcode versions you’re using. 54 | 55 | **Please ensure that SwiftLint returns no errors or warnings before you send in changes.** 56 | 57 | 58 | ## Credits 59 | 60 | Control Room was originally designed and built by Paul Hudson, and is copyright © Paul Hudson 2023. The icon was designed by Raphael Lopes. 61 | 62 | Control Room is licensed under the MIT license; for the full license please see the [LICENSE file](LICENSE). Many other folks have contributed features, fixes, and more to make Control Room what it is today. Control Room is built on top of Apple’s **simctl** command – the team who built that deserve the real credit here. 63 | 64 | Swift, the Swift logo, and Xcode are trademarks of Apple Inc., registered in the U.S. and other countries. 65 | 66 | If you find Control Room useful, you might find my website full of Swift tutorials equally useful: [Hacking with Swift](https://www.hackingwithswift.com). 67 | -------------------------------------------------------------------------------- /Working/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/Working/logo.png -------------------------------------------------------------------------------- /Working/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/Working/logo.psd -------------------------------------------------------------------------------- /Working/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/Working/logo.sketch --------------------------------------------------------------------------------