The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | <?xml version="1.0" encoding="UTF-8"?>
2 | <Workspace
3 |    version = "1.0">
4 |    <FileRef
5 |       location = "self:">
6 |    </FileRef>
7 | </Workspace>
8 | 


--------------------------------------------------------------------------------
/ControlRoom.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 | <plist version="1.0">
4 | <dict>
5 | 	<key>IDEDidComputeMac32BitWarning</key>
6 | 	<true/>
7 | </dict>
8 | </plist>
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 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <Scheme
 3 |    LastUpgradeVersion = "1430"
 4 |    version = "1.3">
 5 |    <BuildAction
 6 |       parallelizeBuildables = "YES"
 7 |       buildImplicitDependencies = "YES">
 8 |       <BuildActionEntries>
 9 |          <BuildActionEntry
10 |             buildForTesting = "YES"
11 |             buildForRunning = "YES"
12 |             buildForProfiling = "YES"
13 |             buildForArchiving = "YES"
14 |             buildForAnalyzing = "YES">
15 |             <BuildableReference
16 |                BuildableIdentifier = "primary"
17 |                BlueprintIdentifier = "511BA57823F3FFEA00E3E660"
18 |                BuildableName = "Control Room.app"
19 |                BlueprintName = "ControlRoom"
20 |                ReferencedContainer = "container:ControlRoom.xcodeproj">
21 |             </BuildableReference>
22 |          </BuildActionEntry>
23 |       </BuildActionEntries>
24 |    </BuildAction>
25 |    <TestAction
26 |       buildConfiguration = "Debug"
27 |       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28 |       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29 |       shouldUseLaunchSchemeArgsEnv = "YES">
30 |       <Testables>
31 |          <TestableReference
32 |             skipped = "NO">
33 |             <BuildableReference
34 |                BuildableIdentifier = "primary"
35 |                BlueprintIdentifier = "B07F584D23F9F83800256D5D"
36 |                BuildableName = "ControlRoomTests.xctest"
37 |                BlueprintName = "ControlRoomTests"
38 |                ReferencedContainer = "container:ControlRoom.xcodeproj">
39 |             </BuildableReference>
40 |          </TestableReference>
41 |       </Testables>
42 |    </TestAction>
43 |    <LaunchAction
44 |       buildConfiguration = "Debug"
45 |       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
46 |       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
47 |       launchStyle = "0"
48 |       useCustomWorkingDirectory = "NO"
49 |       ignoresPersistentStateOnLaunch = "NO"
50 |       debugDocumentVersioning = "YES"
51 |       debugServiceExtension = "internal"
52 |       allowLocationSimulation = "YES">
53 |       <BuildableProductRunnable
54 |          runnableDebuggingMode = "0">
55 |          <BuildableReference
56 |             BuildableIdentifier = "primary"
57 |             BlueprintIdentifier = "511BA57823F3FFEA00E3E660"
58 |             BuildableName = "Control Room.app"
59 |             BlueprintName = "ControlRoom"
60 |             ReferencedContainer = "container:ControlRoom.xcodeproj">
61 |          </BuildableReference>
62 |       </BuildableProductRunnable>
63 |    </LaunchAction>
64 |    <ProfileAction
65 |       buildConfiguration = "Release"
66 |       shouldUseLaunchSchemeArgsEnv = "YES"
67 |       savedToolIdentifier = ""
68 |       useCustomWorkingDirectory = "NO"
69 |       debugDocumentVersioning = "YES">
70 |       <BuildableProductRunnable
71 |          runnableDebuggingMode = "0">
72 |          <BuildableReference
73 |             BuildableIdentifier = "primary"
74 |             BlueprintIdentifier = "511BA57823F3FFEA00E3E660"
75 |             BuildableName = "Control Room.app"
76 |             BlueprintName = "ControlRoom"
77 |             ReferencedContainer = "container:ControlRoom.xcodeproj">
78 |          </BuildableReference>
79 |       </BuildableProductRunnable>
80 |    </ProfileAction>
81 |    <AnalyzeAction
82 |       buildConfiguration = "Debug">
83 |    </AnalyzeAction>
84 |    <ArchiveAction
85 |       buildConfiguration = "Release"
86 |       revealArchiveInOrganizer = "YES">
87 |    </ArchiveAction>
88 | </Scheme>
89 | 


--------------------------------------------------------------------------------
/ControlRoom.xcodeproj/xcshareddata/xcschemes/Release - ControlRoom.xcscheme:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <Scheme
 3 |    LastUpgradeVersion = "1430"
 4 |    version = "1.3">
 5 |    <BuildAction
 6 |       parallelizeBuildables = "YES"
 7 |       buildImplicitDependencies = "YES">
 8 |       <BuildActionEntries>
 9 |          <BuildActionEntry
10 |             buildForTesting = "YES"
11 |             buildForRunning = "YES"
12 |             buildForProfiling = "YES"
13 |             buildForArchiving = "YES"
14 |             buildForAnalyzing = "YES">
15 |             <BuildableReference
16 |                BuildableIdentifier = "primary"
17 |                BlueprintIdentifier = "511BA57823F3FFEA00E3E660"
18 |                BuildableName = "Control Room.app"
19 |                BlueprintName = "ControlRoom"
20 |                ReferencedContainer = "container:ControlRoom.xcodeproj">
21 |             </BuildableReference>
22 |          </BuildActionEntry>
23 |       </BuildActionEntries>
24 |    </BuildAction>
25 |    <TestAction
26 |       buildConfiguration = "Debug"
27 |       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28 |       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29 |       shouldUseLaunchSchemeArgsEnv = "YES">
30 |       <Testables>
31 |          <TestableReference
32 |             skipped = "NO">
33 |             <BuildableReference
34 |                BuildableIdentifier = "primary"
35 |                BlueprintIdentifier = "B07F584D23F9F83800256D5D"
36 |                BuildableName = "ControlRoomTests.xctest"
37 |                BlueprintName = "ControlRoomTests"
38 |                ReferencedContainer = "container:ControlRoom.xcodeproj">
39 |             </BuildableReference>
40 |          </TestableReference>
41 |       </Testables>
42 |    </TestAction>
43 |    <LaunchAction
44 |       buildConfiguration = "Release"
45 |       selectedDebuggerIdentifier = ""
46 |       selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
47 |       disableMainThreadChecker = "YES"
48 |       launchStyle = "0"
49 |       useCustomWorkingDirectory = "NO"
50 |       ignoresPersistentStateOnLaunch = "NO"
51 |       debugDocumentVersioning = "YES"
52 |       debugServiceExtension = "internal"
53 |       allowLocationSimulation = "YES">
54 |       <BuildableProductRunnable
55 |          runnableDebuggingMode = "0">
56 |          <BuildableReference
57 |             BuildableIdentifier = "primary"
58 |             BlueprintIdentifier = "511BA57823F3FFEA00E3E660"
59 |             BuildableName = "Control Room.app"
60 |             BlueprintName = "ControlRoom"
61 |             ReferencedContainer = "container:ControlRoom.xcodeproj">
62 |          </BuildableReference>
63 |       </BuildableProductRunnable>
64 |    </LaunchAction>
65 |    <ProfileAction
66 |       buildConfiguration = "Release"
67 |       shouldUseLaunchSchemeArgsEnv = "YES"
68 |       savedToolIdentifier = ""
69 |       useCustomWorkingDirectory = "NO"
70 |       debugDocumentVersioning = "YES">
71 |       <BuildableProductRunnable
72 |          runnableDebuggingMode = "0">
73 |          <BuildableReference
74 |             BuildableIdentifier = "primary"
75 |             BlueprintIdentifier = "511BA57823F3FFEA00E3E660"
76 |             BuildableName = "Control Room.app"
77 |             BlueprintName = "ControlRoom"
78 |             ReferencedContainer = "container:ControlRoom.xcodeproj">
79 |          </BuildableReference>
80 |       </BuildableProductRunnable>
81 |    </ProfileAction>
82 |    <AnalyzeAction
83 |       buildConfiguration = "Debug">
84 |    </AnalyzeAction>
85 |    <ArchiveAction
86 |       buildConfiguration = "Release"
87 |       revealArchiveInOrganizer = "YES">
88 |    </ArchiveAction>
89 | </Scheme>
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 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3 | <plist version="1.0">
 4 | <dict>
 5 | 	<key>com.apple.security.automation.apple-events</key>
 6 | 	<true/>
 7 | 	<key>com.apple.security.temporary-exception.apple-events</key>
 8 | 	<array>
 9 | 		<string>com.apple.terminal</string>
10 | 		<string>com.apple.terminal.shell-script</string>
11 | 		<string>com.apple.terminal.settings</string>
12 | 		<string>com.apple.terminal.session</string>
13 | 	</array>
14 | </dict>
15 | </plist>
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<DeepLink>]) {
 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+)?)
quot;)
 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<Void, Never>()
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-]+)
quot;#, 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<DeviceFamily> {
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<DeveloperTool, Never> {
 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<String, XcodeSelect.Error> {
 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.DeveloperToolsList, SystemProfiler.Error> = 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<K>.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<K>.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<Value> {
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<Value> {
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<Data, CommandLineError>) -> 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<Data, CommandLineError>) -> 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<Data, CommandLineError> {
 86 |         let publisher = PassthroughSubject<Data, CommandLineError>()
 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<Data, CommandLineError>) -> Void)? = nil) {
102 |         execute(command, completion: completion ?? { _ in })
103 |     }
104 | 
105 |     static func executeJSON<T: Decodable>(_ command: Command) -> AnyPublisher<T, CommandLineError> {
106 |         executeAndDecode(command, decoder: JSONDecoder())
107 |     }
108 | 
109 |     static func executePropertyList<T: Decodable>(_ command: Command) -> AnyPublisher<T, CommandLineError> {
110 |         executeAndDecode(command, decoder: PropertyListDecoder())
111 |     }
112 | 
113 |     private static func executeAndDecode<Item, Decoder>(_ command: Command, decoder: Decoder) -> AnyPublisher<Item, CommandLineError> 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, CommandLineError>) -> 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<Items, Content>: 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<ID: Hashable, V: View>: 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+
quot;, 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 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3 | <plist version="1.0">
 4 | <dict>
 5 | 	<key>CFBundleDevelopmentRegion</key>
 6 | 	<string>$(DEVELOPMENT_LANGUAGE)</string>
 7 | 	<key>CFBundleExecutable</key>
 8 | 	<string>$(EXECUTABLE_NAME)</string>
 9 | 	<key>CFBundleIconFile</key>
10 | 	<string></string>
11 | 	<key>CFBundleIdentifier</key>
12 | 	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 | 	<key>CFBundleInfoDictionaryVersion</key>
14 | 	<string>6.0</string>
15 | 	<key>CFBundleName</key>
16 | 	<string>Control Room</string>
17 | 	<key>CFBundlePackageType</key>
18 | 	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19 | 	<key>CFBundleShortVersionString</key>
20 | 	<string>$(MARKETING_VERSION)</string>
21 | 	<key>CFBundleVersion</key>
22 | 	<string>1</string>
23 | 	<key>LSApplicationCategoryType</key>
24 | 	<string>public.app-category.developer-tools</string>
25 | 	<key>LSMinimumSystemVersion</key>
26 | 	<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27 | 	<key>NSHumanReadableCopyright</key>
28 | 	<string>Copyright © 2023 Paul Hudson. All rights reserved.</string>
29 | 	<key>NSMainStoryboardFile</key>
30 | 	<string>Main</string>
31 | 	<key>NSPrincipalClass</key>
32 | 	<string>NSApplication</string>
33 | 	<key>NSSupportsAutomaticTermination</key>
34 | 	<true/>
35 | 	<key>NSSupportsSuddenTermination</key>
36 | 	<true/>
37 | 	<key>NSAppleEventsUsageDescription</key>
38 | 	<string>Do you want to allow automation for this app?</string>
39 | </dict>
40 | </plist>
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<AnyCancellable>()
 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<Content: View>: 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<String>, 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<String>
45 |         let onClear: () -> Void
46 | 
47 |         init(binding: Binding<String>, 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 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3 | <plist version="1.0">
 4 | <dict>
 5 | 	<key>CFBundleDevelopmentRegion</key>
 6 | 	<string>$(DEVELOPMENT_LANGUAGE)</string>
 7 | 	<key>CFBundleExecutable</key>
 8 | 	<string>$(EXECUTABLE_NAME)</string>
 9 | 	<key>CFBundleIdentifier</key>
10 | 	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11 | 	<key>CFBundleInfoDictionaryVersion</key>
12 | 	<string>6.0</string>
13 | 	<key>CFBundleName</key>
14 | 	<string>$(PRODUCT_NAME)</string>
15 | 	<key>CFBundlePackageType</key>
16 | 	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
17 | 	<key>CFBundleShortVersionString</key>
18 | 	<string>1.0</string>
19 | 	<key>CFBundleVersion</key>
20 | 	<string>1</string>
21 | </dict>
22 | </plist>
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 | <p align="center">
 2 |     <img src="https://www.hackingwithswift.com/files/controlroom/logo.png" alt="Control Room logo" width="400” maxHeight="91" />
 3 | </p>
 4 | 
 5 | <p align="center">
 6 |     <img src="https://img.shields.io/badge/macOS-13+-blue.svg" />
 7 |     <img src="https://img.shields.io/badge/Swift-5.8-brightgreen.svg" />
 8 |     <a href="https://twitter.com/twostraws">
 9 |         <img src="https://img.shields.io/badge/Contact-@twostraws-lightgrey.svg?style=flat" alt="Twitter: @twostraws" />
10 |     </a>
11 | </p>
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


--------------------------------------------------------------------------------