, onClear: @escaping () -> Void) {
48 | self.binding = binding
49 | self.onClear = onClear
50 | super.init()
51 | }
52 |
53 | func controlTextDidChange(_ obj: Notification) {
54 | guard let field = obj.object as? NSTextField else { return }
55 | binding.wrappedValue = field.stringValue
56 |
57 | if field.stringValue.isEmpty {
58 | onClear()
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/ControlRoom/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/ControlRoom/Settings UI/ColorPickerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ColorPickerView.swift
3 | // ControlRoom
4 | //
5 | // Created by Elliot Knight on 11/05/2024.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ColorPickerView: View {
12 | /// Whether hex strings should be printed in uppercase or not.
13 | @AppStorage("CRColorPickerUppercaseHex") var uppercaseHex = true
14 |
15 | /// How many decimal places to use for rounding picked colors.
16 | @AppStorage("CRColorPickerAccuracy") var colorPickerAccuracy = 2
17 |
18 | var body: some View {
19 | VStack {
20 | Toggle("Uppercase Hex Strings", isOn: $uppercaseHex)
21 | .padding(.bottom)
22 |
23 | Text("Set the maximum number of decimal places to use when generating code for picked simulator colors. The default is 2.")
24 | Stepper("Decimal Places: \(colorPickerAccuracy)", value: $colorPickerAccuracy, in: 0...5)
25 | .pickerStyle(.segmented)
26 | }
27 | }
28 | }
29 |
30 | #Preview {
31 | ColorPickerView()
32 | }
33 |
--------------------------------------------------------------------------------
/ControlRoom/Settings UI/NotificationsFormView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotificationsFormView.swift
3 | // ControlRoom
4 | //
5 | // Created by Elliot Knight on 11/05/2024.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 | import KeyboardShortcuts
11 |
12 | struct NotificationsFormView: View {
13 | var body: some View {
14 | Form {
15 | makeKeyboardShortcut(title: "Resend last push notification", for: .resendLastPushNotification)
16 | makeKeyboardShortcut(title: "Restart last selected app", for: .restartLastSelectedApp)
17 | makeKeyboardShortcut(title: "Reopen last URL", for: .reopenLastURL)
18 | }
19 | }
20 |
21 | private func makeKeyboardShortcut(title: String, for name: KeyboardShortcuts.Name) -> some View {
22 | HStack {
23 | Text(title)
24 | KeyboardShortcuts.Recorder(for: name)
25 | }
26 | }
27 | }
28 |
29 | #Preview {
30 | NotificationsFormView()
31 | }
32 |
--------------------------------------------------------------------------------
/ControlRoom/Settings UI/PathToTerminalTextFieldView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PathToTerminalTextFieldView.swift
3 | // ControlRoom
4 | //
5 | // Created by Elliot Knight on 11/05/2024.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct PathToTerminalTextFieldView: View {
12 | @EnvironmentObject var preferences: Preferences
13 |
14 | var body: some View {
15 | Form {
16 | TextField(
17 | "Path to Terminal",
18 | text: $preferences.terminalAppPath
19 | )
20 | .textFieldStyle(.roundedBorder)
21 | }
22 | }
23 | }
24 |
25 | #Preview {
26 | PathToTerminalTextFieldView()
27 | .environmentObject(Preferences())
28 | }
29 |
--------------------------------------------------------------------------------
/ControlRoom/Settings UI/PickersFormView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PickersFormView.swift
3 | // ControlRoom
4 | //
5 | // Created by Elliot Knight on 11/05/2024.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct PickersFormView: View {
12 | /// The user's settings for capturing
13 | @AppStorage("captureSettings") var captureSettings = CaptureSettings(imageFormat: .png, videoFormat: .h264, display: .internal, mask: .ignored, saveURL: .desktop)
14 |
15 | /// Whether the user wants us to render device bezels around their screenshots.
16 | /// Note: this requires a mask of alpha, so we enforce that when true.
17 | @AppStorage("renderChrome") var renderChrome = false
18 | @State private var showFileImporter = false
19 |
20 | var body: some View {
21 | Form {
22 | Picker("Screenshot Format:", selection: $captureSettings.imageFormat) {
23 | ForEach(SimCtl.IO.ImageFormat.allCases, id: \.self) { type in
24 | Text(type.rawValue.uppercased()).tag(type)
25 | }
26 | }
27 |
28 | Picker("Video Format:", selection: $captureSettings.videoFormat) {
29 | ForEach(SimCtl.IO.VideoFormat.all, id: \.self) { item in
30 | if item == .divider {
31 | Divider()
32 | } else {
33 | Text(item.name).tag(item)
34 | }
35 | }
36 | }
37 |
38 | Picker("Display:", selection: $captureSettings.display) {
39 | ForEach(SimCtl.IO.Display.allCases, id: \.self) { display in
40 | Text(display.rawValue.capitalized).tag(display)
41 | }
42 | }
43 |
44 | Picker("Mask:", selection: $captureSettings.mask) {
45 | ForEach(SimCtl.IO.Mask.allCases, id: \.self) { mask in
46 | Text(mask.rawValue.capitalized).tag(mask)
47 | }
48 | }
49 | .disabled(renderChrome)
50 |
51 | Button("Save to: \(captureSettings.saveURL.rawValue)") {
52 | showFileImporter = true
53 | }
54 |
55 | Toggle(isOn: $renderChrome.onChange(updateChromeSettings)) {
56 | VStack(alignment: .leading) {
57 | Text("Add device chrome to screenshots")
58 | Text("This is an experimental feature and may not function properly yet.")
59 | .font(.caption)
60 | }
61 | }
62 | }
63 | .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.directory]) { result in
64 | switch result {
65 | case .success(let success):
66 | captureSettings.saveURL = .other(success)
67 | case .failure:
68 | captureSettings.saveURL = .desktop
69 | }
70 | }
71 | }
72 |
73 | private func updateChromeSettings() {
74 | if renderChrome {
75 | captureSettings.mask = .alpha
76 | }
77 | }
78 | }
79 |
80 | #Preview {
81 | PickersFormView()
82 | }
83 |
--------------------------------------------------------------------------------
/ControlRoom/Settings UI/SettingsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsView.swift
3 | // ControlRoom
4 | //
5 | // Created by Dave DeLong on 2/16/20.
6 | // Copyright © 2020 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import KeyboardShortcuts
10 | import SwiftUI
11 |
12 | struct SettingsView: View {
13 | var body: some View {
14 | TabView {
15 | TogglesFormView()
16 | .padding()
17 | .frame(maxWidth: .infinity, maxHeight: .infinity)
18 | .tabItem {
19 | Label("Window", systemImage: "macwindow")
20 | }
21 |
22 | NotificationsFormView()
23 | .padding()
24 | .frame(maxWidth: .infinity, maxHeight: .infinity)
25 | .tabItem {
26 | Label("Shortcuts", systemImage: "keyboard")
27 | }
28 |
29 | PickersFormView()
30 | .padding()
31 | .frame(maxWidth: .infinity, maxHeight: .infinity)
32 | .tabItem {
33 | Label("Screenshots", systemImage: "camera.on.rectangle")
34 | }
35 |
36 | ColorPickerView()
37 | .padding()
38 | .frame(maxWidth: .infinity, maxHeight: .infinity)
39 | .tabItem {
40 | Label("Colors", systemImage: "paintpalette")
41 | }
42 |
43 | PathToTerminalTextFieldView()
44 | .padding()
45 | .frame(maxWidth: .infinity, maxHeight: .infinity)
46 | .tabItem {
47 | Label("Locations", systemImage: "externaldrive")
48 | }
49 | }
50 | .frame(minWidth: 550)
51 | }
52 | }
53 |
54 | #Preview {
55 | SettingsView()
56 | .environmentObject(Preferences())
57 | }
58 |
--------------------------------------------------------------------------------
/ControlRoom/Settings UI/TogglesFormView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TogglesFormView.swift
3 | // ControlRoom
4 | //
5 | // Created by Elliot Knight on 11/05/2024.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct TogglesFormView: View {
12 | @EnvironmentObject private var preferences: Preferences
13 | var body: some View {
14 | Form {
15 | Toggle("Keep window on top", isOn: $preferences.wantsFloatingWindow)
16 | Toggle("Show Default simulator", isOn: $preferences.showDefaultSimulator)
17 | Toggle("Show booted devices first", isOn: $preferences.showBootedDevicesFirst)
18 | Toggle("Show icon in menu bar", isOn: $preferences.wantsMenuBarIcon)
19 | }
20 | }
21 | }
22 |
23 | #Preview {
24 | TogglesFormView()
25 | .environmentObject(Preferences())
26 | }
27 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/AppView/AppIcon.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppIcon.swift
3 | // ControlRoom
4 | //
5 | // Created by Paul Hudson on 28/01/2021.
6 | // Copyright © 2021 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct AppIcon: View {
12 | let application: Application
13 | let width: CGFloat
14 |
15 | var body: some View {
16 | if let icon = application.icon {
17 | Image(nsImage: icon)
18 | .resizable()
19 | .cornerRadius(width / 5)
20 | .frame(width: width, height: width)
21 | } else {
22 | Rectangle()
23 | .fill(Color.clear)
24 | .overlay(
25 | RoundedRectangle(cornerRadius: width / 5)
26 | .stroke(Color.primary, style: StrokeStyle(lineWidth: 0.5, dash: [width / 20 + 1]))
27 | )
28 | .frame(width: width, height: width)
29 | }
30 | }
31 | }
32 |
33 | struct AppIcon_Previews: PreviewProvider {
34 | static var previews: some View {
35 | AppIcon(application: Application.default, width: 100)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/AppView/AppSummaryView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppSummaryView.swift
3 | // ControlRoom
4 | //
5 | // Created by Paul Hudson on 28/01/2021.
6 | // Copyright © 2021 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct AppSummaryView: View {
12 | let application: Application
13 |
14 | var body: some View {
15 | HStack {
16 | AppIcon(application: application, width: 60)
17 |
18 | VStack(alignment: .leading) {
19 | Text(application.displayName)
20 | .font(.headline)
21 | Text(application.versionNumber.isNotEmpty ? "Version \(application.versionNumber)" : "")
22 | .font(.caption)
23 | Text(application.buildNumber.isNotEmpty ? "Build \(application.buildNumber)" : "")
24 | .font(.caption)
25 | }
26 | }
27 | }
28 | }
29 |
30 | struct AppSummaryView_Previews: PreviewProvider {
31 | static var previews: some View {
32 | AppSummaryView(application: Application.default)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/AppView/NotificationEditorView.strings:
--------------------------------------------------------------------------------
1 | "NotificationView.Hints.APS" = "This JSON is generated according to form values.";
2 | "NotificationView.Hints.UserInfo" = "In this field it is possible to add user defined fields in a key-value format.\n\nExample:\n\"aField\": 2,\n\"anArray\": [1, 2, 3],\n\"aDictionary\": { \"aString\": \"hello\" }\n";
3 | "NotificationView.Hints.InvalidNotificationJson" = "The JSON is not valid. Please double check the user info field making sure it is in a key-value format without opening and closing braces.";
4 | "NotificationView.Hints.Alert.Title" = "The title of the notification. Apple Watch displays this string in the short look notification interface. Specify a string that is quickly understood by the user.";
5 | "NotificationView.Hints.Alert.Subtitle" = "Additional information that explains the purpose of the notification.";
6 | "NotificationView.Hints.Alert.Body" = "The content of the alert message.";
7 | "NotificationView.Hints.Alert.TitleLocKey" = "The key for a localized title string. Specify this key instead of the title key to retrieve the title from your app’s Localizable.strings files. The value must contain the name of a key in your strings file.";
8 | "NotificationView.Hints.Alert.TitleLocArgs" = "An array of strings separated by comma (,) containing replacement values for variables in your title string. Each %@ character in the string specified by the titleLocalizedKey is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on.";
9 | "NotificationView.Hints.Alert.SubtitleLocKey" = "The key for a localized subtitle string. Use this key, instead of the subtitle key, to retrieve the subtitle from your app’s Localizable.strings file. The value must contain the name of a key in your strings file.";
10 | "NotificationView.Hints.Alert.SubtitleLocArgs" = "An array of strings separated by comma (,) containing replacement values for variables in your subtitle string. Each %@ character in the string specified by subtitle-loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on.";
11 | "NotificationView.Hints.Alert.BodyLocKey" = "The key for a localized message string. Use this key, instead of the body key, to retrieve the message text from your app’s Localizable.strings file. The value must contain the name of a key in your strings file.";
12 | "NotificationView.Hints.Alert.BodyLocArgs" = "An array of strings separated by comma (,) containing replacement values for variables in your message text. Each %@ character in the string specified by loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on.";
13 | "NotificationView.Hints.Sound.Name" = "The name of a sound file in your app’s main bundle or in the Library/Sounds folder of your app’s container directory. Specify the string “default” to play the system sound. For information about how to prepare sounds, see UNNotificationSound.";
14 | "NotificationView.Hints.Sound.Critical" = "The critical alert flag. Set to true to enable the critical alert.";
15 | "NotificationView.Hints.Sound.Volume" = "The volume for the critical alert’s sound. Set this to a value between 0.0 (silent) and 1.0 (full volume).";
16 | "NotificationView.Hints.Badge" = "The number to display in a badge on your app’s icon. Specify 0 to remove the current badge, if any.";
17 | "NotificationView.Hints.LaunchImage" = "The name of the launch image file to display. If the user chooses to launch your app, the contents of the specified image or storyboard file are displayed instead of your app’s normal launch image.";
18 | "NotificationView.Hints.ThreadIdentifier" = "An app-specific identifier for grouping related notifications. This value corresponds to the threadIdentifier property in the UNNotificationContent object.";
19 | "NotificationView.Hints.Category" = "The notification’s type. This string must correspond to the identifier of one of the UNNotificationCategory objects you register at launch time.";
20 | "NotificationView.Hints.SilentNotification" = "The background notification flag. To perform a silent background update, specify the value true and don’t include the alert, badge, or sound keys in your payload.";
21 | "NotificationView.Hints.MutableContent" = "The notification service app extension flag. If the value is true, the system passes the notification to your notification service app extension before delivery. Use your extension to modify the notification’s content.";
22 | "NotificationView.Hints.TargetContentIdentifier" = "The identifier of the window brought forward. The value of this key will be populated on the UNNotificationContent object created from the push payload. Access the value using the UNNotificationContent object’s targetContentIdentifier property.";
23 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/ColorsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ColorsView.swift
3 | // ControlRoom
4 | //
5 | // Created by Paul Hudson on 16/05/2023.
6 | // Copyright © 2023 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ColorsView: View {
12 | enum ColorOption {
13 | case hex, swiftUI, uiKit
14 | }
15 |
16 | @State private var pickedColor = PickedColor.default
17 |
18 | @AppStorage("CRColorPickerAccuracy") var colorPickerAccuracy = 2
19 | @AppStorage("CRColorPickerUppercaseHex") var uppercaseHex = true
20 |
21 | @StateObject private var colorHistoryController = ColorHistoryController()
22 | @State private var previouslyPickedSelection: PickedColor.ID?
23 |
24 | var body: some View {
25 | VStack {
26 | Button {
27 | Task {
28 | let selectedColor = await NSColorSampler().sample()
29 |
30 | if let newPickedColor = colorHistoryController.add(selectedColor) {
31 | previouslyPickedSelection = newPickedColor.id
32 | pickedColor = newPickedColor
33 | }
34 | }
35 | } label: {
36 | Label("Select Color", systemImage: "eyedropper")
37 | }
38 |
39 | if let pickedColor {
40 | HStack(spacing: 10) {
41 | Circle()
42 | .fill(pickedColor.swiftUIColor)
43 | .overlay {
44 | Circle()
45 | .strokeBorder(.primary, lineWidth: 1)
46 | }
47 | .frame(width: 50, height: 50)
48 |
49 | Text(pickedColor.hex)
50 | .font(.title)
51 | .textCase(uppercaseHex ? .uppercase : .lowercase)
52 | .textSelection(.enabled)
53 | }
54 | .draggable(assetCatalogData(for: pickedColor))
55 | .padding(10)
56 |
57 | Form {
58 | LabeledContent("SwiftUI code:") {
59 | Text(pickedColor.swiftUICode(roundedTo: colorPickerAccuracy))
60 | .font(.body.monospaced())
61 | .textSelection(.enabled)
62 | }
63 |
64 | LabeledContent("UIKit code:") {
65 | Text(pickedColor.uiKitCode(roundedTo: colorPickerAccuracy))
66 | .font(.body.monospaced())
67 | .textSelection(.enabled)
68 | }
69 | .padding(.bottom, 10)
70 | }
71 | }
72 |
73 | Spacer()
74 | .frame(height: 40)
75 |
76 | Text("Previous Colors")
77 | .font(.headline)
78 |
79 | Table(of: PickedColor.self, selection: $previouslyPickedSelection.onChange(updatePickedColor)) {
80 | TableColumn("Color") { color in
81 | Circle()
82 | .fill(color.swiftUIColor)
83 | .overlay {
84 | Circle()
85 | .strokeBorder(.primary, lineWidth: 1)
86 | }
87 | .frame(width: 24, height: 24)
88 | }
89 | .width(40)
90 |
91 | TableColumn("Hex") { color in
92 | Text(color.hex)
93 | .textCase(uppercaseHex ? .uppercase : .lowercase)
94 | }
95 | } rows: {
96 | // We create rows by hand so that we can attach an item
97 | // provider for dragging asset catalog color sets.
98 | ForEach(colorHistoryController.colors) { color in
99 | TableRow(color)
100 | .itemProvider {
101 | let provider = NSItemProvider()
102 | Task {
103 | let catalogData = assetCatalogData(for: color)
104 | provider.register(catalogData)
105 | }
106 | return provider
107 | }
108 | }
109 | }
110 |
111 | HStack {
112 | Menu("Copy") {
113 | Button("Hex String") {
114 | copy(as: .hex)
115 | }
116 |
117 | Button("SwiftUI Code") {
118 | copy(as: .swiftUI)
119 | }
120 |
121 | Button("UIKit Code") {
122 | copy(as: .uiKit)
123 | }
124 | }
125 | .menuIndicator(.hidden)
126 | .fixedSize()
127 |
128 | Button("Delete", action: deletePreviouslySelected)
129 | }
130 | .disabled(previouslyPickedSelection == nil)
131 |
132 | Text("**Tip:** You can drag any of the colors from here directly into an Xcode asset catalog ✨")
133 | .padding(.top, 20)
134 | }
135 | .padding()
136 | .tabItem {
137 | Text("Colors")
138 | }
139 | }
140 |
141 | /// Updates the top area picked color to match a historical picked color
142 | func updatePickedColor() {
143 | pickedColor = colorHistoryController.item(with: previouslyPickedSelection) ?? .default
144 | }
145 |
146 | /// Copies a color option to the clipboard using various available formats.
147 | func copy(as option: ColorOption) {
148 | guard let id = previouslyPickedSelection else { return }
149 | guard let pickedColor = colorHistoryController.item(with: id) else { return }
150 |
151 | let colorString: String
152 |
153 | switch option {
154 | case .hex:
155 | if uppercaseHex {
156 | colorString = pickedColor.hex.uppercased()
157 | } else {
158 | colorString = pickedColor.hex.lowercased()
159 | }
160 | case .swiftUI:
161 | colorString = pickedColor.swiftUICode(roundedTo: colorPickerAccuracy)
162 | case .uiKit:
163 | colorString = pickedColor.uiKitCode(roundedTo: colorPickerAccuracy)
164 | }
165 |
166 | NSPasteboard.general.clearContents()
167 | NSPasteboard.general.setString(colorString, forType: .string)
168 | }
169 |
170 | func deletePreviouslySelected() {
171 | colorHistoryController.delete(previouslyPickedSelection)
172 | previouslyPickedSelection = nil
173 | }
174 |
175 | func assetCatalogData(for color: PickedColor) -> URL {
176 | let saveDirectory = URL.temporaryDirectory.appending(path: "New Color.colorset")
177 | try? FileManager.default.createDirectory(at: saveDirectory, withIntermediateDirectories: true)
178 |
179 | let colorSet = XcodeColorSet(red: color.hexRed, green: color.hexGreen, blue: color.hexBlue)
180 |
181 | let contentsURL = saveDirectory.appending(path: "Contents.json")
182 |
183 | let encoder = JSONEncoder()
184 | encoder.outputFormatting = .prettyPrinted
185 |
186 | let encodedData = try? encoder.encode(colorSet)
187 | try? encodedData?.write(to: contentsURL)
188 |
189 | return saveDirectory
190 | }
191 | }
192 |
193 | struct ColorsView_Previews: PreviewProvider {
194 | static var previews: some View {
195 | ColorsView()
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocalSearchRowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalSearchRowView.swift
3 | // ControlRoom
4 | //
5 | // Created by John McEvoy on 29/11/2023.
6 | // Copyright © 2023 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 | import CoreLocation
11 |
12 | struct LocalSearchRowView: View {
13 | @Binding var lastHoverId: UUID?
14 | @State private var isHovered = false
15 | let result: LocalSearchResult
16 | let onTap: () -> Void
17 |
18 | var body: some View {
19 | Button {
20 | onTap()
21 | } label: {
22 | HStack {
23 |
24 | Image(systemName: "mappin.circle.fill")
25 | .symbolRenderingMode(.multicolor)
26 | .font(.system(size: 24))
27 |
28 | VStack(alignment: .leading, spacing: 2) {
29 | Text(result.title)
30 | .font(.body)
31 | .foregroundColor(.primary)
32 | .lineLimit(1)
33 |
34 | if let subtitle = result.subtitle {
35 | Text(subtitle)
36 | .font(.caption)
37 | .foregroundColor(.secondary)
38 | .lineLimit(1)
39 | }
40 | }
41 | Spacer()
42 | }
43 | }
44 | .buttonStyle(.borderless)
45 | .frame(minHeight: 36)
46 | .padding(.horizontal, 8)
47 | .padding(.vertical, 4)
48 | .background(isHovered ? .blue : .clear)
49 | .cornerRadius(8)
50 | .onChange(of: lastHoverId) {
51 | isHovered = $0 == result.id
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/OverridesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OverridesView.swift
3 | // ControlRoom
4 | //
5 | // Created by Paul Hudson on 07/05/2023.
6 | // Copyright © 2023 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct OverridesView: View {
12 | let simulator: Simulator
13 |
14 | /// The system-wide appearance; "Light" or "Dark".
15 | @State private var appearance: SimCtl.UI.Appearance = .light
16 |
17 | /// The currently active language identifier
18 | @State private var language: String = NSLocale.current.language.languageCode?.identifier ?? ""
19 |
20 | /// The currently active locale identifier
21 | @State private var locale: String = NSLocale.current.identifier
22 |
23 | // The current Dynamic Type sizes
24 | @State private var contentSize: SimCtl.UI.ContentSizes = .medium
25 |
26 | @State private var enhanceTextLegibility = false
27 | @State private var showButtonShapes = false
28 | @State private var showOnOffLabels = false
29 | @State private var reduceTransparency = false
30 | @State private var increaseContrast = false
31 | @State private var differentiateWithoutColor = false
32 | @State private var smartInvert = false
33 |
34 | @State private var reduceMotion = false
35 | @State private var preferCrossFadeTransitions = false
36 |
37 | private let languages: [String] = {
38 | NSLocale.isoLanguageCodes
39 | .filter { NSLocale.current.localizedString(forLanguageCode: $0) != nil }
40 | .sorted { lhs, rhs in
41 | let lhsString = NSLocale.current.localizedString(forLanguageCode: lhs) ?? ""
42 | let rhsString = NSLocale.current.localizedString(forLanguageCode: rhs) ?? ""
43 | return lhsString.lowercased() < rhsString.lowercased()
44 | }
45 | }()
46 |
47 | var body: some View {
48 | ScrollView {
49 | Form {
50 | Group {
51 | Picker("Appearance:", selection: $appearance.onChange(updateAppearance)) {
52 | ForEach(SimCtl.UI.Appearance.allCases, id: \.self) {
53 | Text($0.displayName)
54 | }
55 | }
56 | }
57 |
58 | Spacer()
59 | .frame(height: 40)
60 |
61 | Group {
62 | Picker("Language:", selection: $language) {
63 | ForEach(languages, id: \.self) {
64 | Text(NSLocale.current.localizedString(forLanguageCode: $0) ?? "")
65 | }
66 | }
67 | Picker("Locale:", selection: $locale) {
68 | ForEach(locales(for: language), id: \.self) {
69 | Text(NSLocale.current.localizedString(forIdentifier: $0) ?? "")
70 | }
71 | }
72 | HStack {
73 | Button("Set Language/Locale", action: updateLanguage)
74 | Text("(Requires Reboot)").font(.system(size: 11)).foregroundColor(.secondary)
75 | }
76 | }
77 |
78 | Spacer()
79 | .frame(height: 40)
80 |
81 | Section(header:
82 | Text("Accessibility overrides")
83 | .font(.headline)
84 | ) {
85 | Picker("Content size:", selection: $contentSize) {
86 | ForEach(SimCtl.UI.ContentSizes.allCases, id: \.self) { size in
87 | HStack {
88 | Text(size.rawValue)
89 | }
90 | }
91 | }
92 | .onChange(of: contentSize) { _ in
93 | updateContentSize()
94 | }
95 |
96 | Toggle("Bold Text", isOn: $enhanceTextLegibility.onChange(setEnhanceTextLegibility))
97 | Toggle("Button Shapes", isOn: $showButtonShapes.onChange(setShowButtonShapes))
98 | Toggle("On/Off Labels", isOn: $showOnOffLabels.onChange(setShowOnOffLabels))
99 | Toggle("Reduce Transparency", isOn: $reduceTransparency.onChange(setReduceTransparency))
100 | Toggle("Increase Contrast", isOn: $increaseContrast.onChange(setIncreaseContrast))
101 | Toggle("Differentiate Without Color", isOn: $differentiateWithoutColor.onChange(setDifferentiateWithoutColor))
102 | Toggle("Smart Invert", isOn: $smartInvert.onChange(setSmartInvert))
103 | }
104 |
105 | Toggle("Reduce Motion", isOn: $reduceMotion.onChange(setReduceMotion))
106 |
107 | Toggle("Prefer Cross-Fade Transitions", isOn: $preferCrossFadeTransitions.onChange(setPreferCrossFadeTransitions))
108 | .disabled(reduceMotion == false)
109 | }
110 | .padding()
111 | }
112 | .tabItem {
113 | Text("Overrides")
114 | }
115 | }
116 |
117 | /// Moves between light and dark mode.
118 | func updateAppearance() {
119 | SimCtl.setAppearance(simulator.udid, appearance: appearance)
120 | }
121 |
122 | func updateLanguage() {
123 | let plistPath = simulator.dataPath + "/Library/Preferences/.GlobalPreferences.plist"
124 | _ = Process.execute("/usr/bin/xcrun", arguments: ["plutil", "-replace", "AppleLanguages", "-json", "[\"\(language)\" ]", plistPath])
125 | _ = Process.execute("/usr/bin/xcrun", arguments: ["plutil", "-replace", "AppleLocale", "-string", locale, plistPath])
126 | SimCtl.reboot(simulator)
127 | }
128 |
129 | private func locales(for language: String) -> [String] {
130 | NSLocale.availableLocaleIdentifiers
131 | .filter { $0.hasPrefix(language) }
132 | .sorted { (lhs, rhs) -> Bool in
133 | let lhsString = NSLocale.current.localizedString(forIdentifier: lhs) ?? ""
134 | let rhsString = NSLocale.current.localizedString(forIdentifier: rhs) ?? ""
135 | return lhsString.lowercased() < rhsString.lowercased()
136 | }
137 | }
138 |
139 | /// Update Content Size.
140 | func updateContentSize() {
141 | SimCtl.setContentSize(simulator.udid, contentSize: contentSize)
142 | }
143 |
144 | // Updates the simulator's accessibility setting for a particular key.
145 | // Example call: xcrun simctl spawn booted defaults write com.apple.Accessibility EnhancedTextLegibilityEnabled -bool FALSE
146 | func updateAccessibility(key: String, value: Bool) {
147 | _ = Process.execute("/usr/bin/xcrun", arguments: ["simctl", "spawn", simulator.id, "defaults", "write", "com.apple.Accessibility", key, "-bool", String(value)])
148 | }
149 |
150 | func setEnhanceTextLegibility() {
151 | updateAccessibility(key: "EnhancedTextLegibilityEnabled", value: enhanceTextLegibility)
152 | }
153 |
154 | func setShowButtonShapes() {
155 | updateAccessibility(key: "ButtonShapesEnabled", value: showButtonShapes)
156 | }
157 |
158 | func setShowOnOffLabels() {
159 | updateAccessibility(key: "IncreaseButtonLegibilityEnabled", value: showOnOffLabels)
160 | }
161 |
162 | func setReduceTransparency() {
163 | updateAccessibility(key: "EnhancedBackgroundContrastEnabled", value: reduceTransparency)
164 | }
165 |
166 | func setIncreaseContrast() {
167 | updateAccessibility(key: "DarkenSystemColors", value: increaseContrast)
168 | }
169 |
170 | func setDifferentiateWithoutColor() {
171 | updateAccessibility(key: "DifferentiateWithoutColor", value: differentiateWithoutColor)
172 | }
173 |
174 | func setSmartInvert() {
175 | updateAccessibility(key: "InvertColorsEnabled", value: smartInvert)
176 | }
177 |
178 | func setReduceMotion() {
179 | updateAccessibility(key: "ReduceMotionEnabled", value: reduceMotion)
180 |
181 | // Automatically disable the cross-fade animation if reduce motion is being
182 | // disabled. This matches what Settings does.
183 | if reduceMotion == false {
184 | preferCrossFadeTransitions = false
185 | updateAccessibility(key: "ReduceMotionReduceSlideTransitionsPreference", value: false)
186 | }
187 | }
188 |
189 | func setPreferCrossFadeTransitions() {
190 | updateAccessibility(key: "ReduceMotionReduceSlideTransitionsPreference", value: preferCrossFadeTransitions)
191 | }
192 | }
193 |
194 | struct OverridesView_Previews: PreviewProvider {
195 | static var previews: some View {
196 | OverridesView(simulator: .example)
197 | .environmentObject(Preferences())
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/SnapshotAction.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotAction.swift
3 | // ControlRoom
4 | //
5 | // Created by Marcel Mendes on 14/12/24.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import struct SwiftUI.LocalizedStringKey
10 |
11 | enum SnapshotAction: Int, Identifiable {
12 | case delete
13 | case rename
14 | case restore
15 |
16 | var id: Int { rawValue }
17 |
18 | var sheetTitle: LocalizedStringKey {
19 | switch self {
20 | case .delete: "Delete Snapshot"
21 | case .rename: "Rename Snapshot"
22 | case .restore: "Restore Snapshot"
23 | }
24 | }
25 |
26 | var sheetMessage: LocalizedStringKey {
27 | switch self {
28 | case .delete: "Are you sure you want to delete this snapshot? You will not be able to undo this action."
29 | case .rename: "Enter a new name for this snapshot. It must be unique."
30 | case .restore: "Are you sure you want to restore this snapshot? You will not be able to undo this action."
31 | }
32 | }
33 |
34 | var saveActionTitle: LocalizedStringKey {
35 | switch self {
36 | case .delete: "Delete"
37 | case .rename: "Rename"
38 | case .restore: "Restore"
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/SnapshotsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotsView.swift
3 | // ControlRoom
4 | //
5 | // Created by Marcel Mendes on 14/12/24.
6 | // Copyright © 2024 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct SnapshotsView: View {
12 | let simulator: Simulator
13 | @ObservedObject var controller: SimulatorsController
14 |
15 | @State private var snapshotAction: SnapshotAction?
16 | @State private var newName: String
17 | @State private var selectedSnapshotName: String
18 |
19 | init(simulator: Simulator, controller: SimulatorsController) {
20 | self.simulator = simulator
21 | self.controller = controller
22 | self._newName = State(initialValue: simulator.name)
23 | self._selectedSnapshotName = State(initialValue: simulator.name)
24 | }
25 |
26 | private let formatter = MeasurementFormatter()
27 |
28 | var body: some View {
29 | ScrollView {
30 | if controller.snapshots.count > 0 {
31 | Form {
32 | Section {
33 | LabeledContent("Snapshots:") {
34 | VStack(alignment: .leading, spacing: 5) {
35 | ForEach(controller.snapshots.sorted(by: { $0.creationDate > $1.creationDate }), id: \.id) { snapshot in
36 |
37 | let folderSize = Measurement(value: Double(snapshot.size), unit: UnitInformationStorage.bytes)
38 |
39 | HStack {
40 | Button {
41 | restore(snapshot: snapshot.id)
42 | } label: {
43 | Label("Restore", systemImage: "arrow.counterclockwise")
44 | }
45 |
46 | Button {
47 | rename(snapshot: snapshot.id)
48 | } label: {
49 | Label("Rename", systemImage: "pencil")
50 | }
51 |
52 | Text(snapshot.id)
53 | .fontWeight(.semibold)
54 |
55 | Group {
56 | Text(snapshot.creationDate.formatted(date: .numeric, time: .standard))
57 | Text(formatter.string(from: folderSize.converted(to: .gigabytes)))
58 | }
59 | .font(.callout)
60 | .fontWeight(.thin)
61 |
62 | Button {
63 | delete(snapshot: snapshot.id)
64 | } label: {
65 | Label("Delete", systemImage: "trash")
66 | }
67 |
68 | Spacer()
69 | }
70 | }
71 | }
72 | }
73 | }
74 | }
75 | .padding()
76 | } else {
77 | VStack(spacing: 10) {
78 | Spacer()
79 | Image(systemName: simulator.deviceFamily.snapshotUnavailableIcon)
80 | Text("No snapshots yet")
81 | }
82 | .font(.title)
83 | }
84 | }
85 | .tabItem {
86 | Text("Snapshots")
87 | }
88 | .sheet(item: $snapshotAction) { action in
89 | switch action {
90 | case .rename:
91 | SimulatorActionSheet(
92 | icon: simulator.image,
93 | message: action.sheetTitle,
94 | informativeText: action.sheetMessage,
95 | confirmationTitle: action.saveActionTitle,
96 | confirm: { performAction(action) },
97 | canConfirm: newName.isNotEmpty,
98 | content: {
99 | TextField("Name", text: $newName)
100 | }
101 | )
102 | case .delete, .restore:
103 | SimulatorActionSheet(
104 | icon: simulator.image,
105 | message: action.sheetTitle,
106 | informativeText: action.sheetMessage,
107 | confirmationTitle: action.saveActionTitle,
108 | confirm: { performAction(action) })
109 | }
110 | }
111 |
112 | }
113 |
114 | private func rename(snapshot: String) {
115 | selectedSnapshotName = snapshot
116 | newName = snapshot
117 | snapshotAction = .rename
118 | }
119 |
120 | private func delete(snapshot: String) {
121 | selectedSnapshotName = snapshot
122 | snapshotAction = .delete
123 | }
124 |
125 | private func restore(snapshot: String) {
126 | selectedSnapshotName = snapshot
127 | snapshotAction = .restore
128 | }
129 |
130 | private func performAction(_ action: SnapshotAction) {
131 | switch action {
132 | case .delete: SnapshotCtl.deleteSnapshot(deviceId: simulator.udid, snapshotName: selectedSnapshotName)
133 | case .rename: SnapshotCtl.renameSnapshot(deviceId: simulator.udid, snapshotName: selectedSnapshotName, newSnapshotName: newName)
134 | case .restore: SnapshotCtl.restoreSnapshot(deviceId: simulator.udid, snapshotName: selectedSnapshotName)
135 | }
136 | }
137 |
138 | func placeholder() {}
139 |
140 | }
141 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlScreens/SystemView/DeepLinkEditorView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkEditorView.swift
3 | // ControlRoom
4 | //
5 | // Created by Paul Hudson on 16/05/2023.
6 | // Copyright © 2023 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct DeepLinkEditorView: View {
12 | @EnvironmentObject var deepLinks: DeepLinksController
13 | @Environment(\.dismiss) var dismiss
14 |
15 | /// The link name the user is currently adding.
16 | @State private var newLinkName = ""
17 |
18 | /// The link URL the user is currently adding.
19 | @State private var newLinkURL = ""
20 |
21 | /// The order we're displaying our links, defaulting to name.
22 | @State private var sortOrder = [KeyPathComparator(\DeepLink.name)]
23 |
24 | /// The currently selected deep link, or nil if nothing is selected.
25 | @State private var selection: DeepLink.ID?
26 |
27 | /// Whether we are currently showing the alert to let the user add a new deep link.
28 | @State private var showingAddAlert = false
29 |
30 | /// Whether we are currently showing the sheet to let the user edit an existing deep link.
31 | @State private var showingEditSheet = false
32 |
33 | var body: some View {
34 | VStack {
35 | Text("Saved Deep Links")
36 | .font(.title)
37 |
38 | Text("Create named deep links or other URLs to make them easier to open repeatedly inside Control Room. **Tip:** Adjusting the sort order adjusts the order here, in the System tab, and in the menu bar list.")
39 |
40 | if deepLinks.links.isEmpty {
41 | Spacer()
42 | Text("No saved deep links created yet.")
43 | Spacer()
44 | } else {
45 | Table(deepLinks.links, selection: $selection, sortOrder: $sortOrder) {
46 | TableColumn("Name", value: \.name)
47 | TableColumn("URL", value: \.url.absoluteString)
48 | }
49 | .contextMenu(forSelectionType: DeepLink.ID.self) { _ in
50 |
51 | } primaryAction: { _ in
52 | showingEditSheet.toggle()
53 | }
54 | }
55 |
56 | HStack {
57 | Button("Add New") {
58 | showingAddAlert.toggle()
59 | }
60 |
61 | Button("Edit") {
62 | showingEditSheet.toggle()
63 | }
64 | .disabled(selection == nil)
65 |
66 | Button("Duplicate") {
67 | duplicateSelected()
68 | }
69 | .disabled(selection == nil)
70 |
71 | Button("Delete") {
72 | deleteSelected()
73 | }
74 | .disabled(selection == nil)
75 |
76 | Spacer()
77 | Button("Done") { dismiss() }
78 | }
79 | }
80 | .frame(width: 500)
81 | .frame(minHeight: 350)
82 | .padding()
83 | .alert("Add new deep link", isPresented: $showingAddAlert) {
84 | TextField("Name", text: $newLinkName)
85 | TextField("URL", text: $newLinkURL)
86 | Button("Add", action: addLink)
87 | Button("Cancel", role: .cancel) { }
88 | } message: {
89 | Text("Make sure you include a schema, e.g. https:// or yourapp://")
90 | }
91 | .sheet(isPresented: $showingEditSheet, content: {
92 | EditDeepLinkView(deepLink: $selection)
93 | })
94 | .onChange(of: sortOrder) { newOrder in
95 | deepLinks.sort(using: newOrder)
96 | }
97 | }
98 |
99 | /// Triggered by our alert, when the user wants to save their new deep link.
100 | func addLink() {
101 | deepLinks.create(name: newLinkName, url: newLinkURL)
102 | newLinkName = ""
103 | newLinkURL = ""
104 | }
105 |
106 | /// Deletes whatever is the currently selected deep link.
107 | func deleteSelected() {
108 | deepLinks.delete(selection)
109 | selection = nil
110 | }
111 |
112 | func duplicateSelected() {
113 | if let link = deepLinks.link(selection) {
114 | deepLinks.create(name: link.name + " (copy)", url: link.url.absoluteString)
115 | }
116 | }
117 | }
118 |
119 | private extension DeepLinkEditorView {
120 | struct EditDeepLinkView: View {
121 | @EnvironmentObject private var deepLinks: DeepLinksController
122 | @Environment(\.dismiss) private var dismiss
123 |
124 | @Binding var deepLink: DeepLink.ID?
125 | @State private var name: String = ""
126 | @State private var url: String = ""
127 |
128 | var body: some View {
129 | VStack {
130 | Text("Edit Deep Link")
131 | .font(.title)
132 |
133 | TextField("Name", text: $name)
134 | TextField("URL", text: $url)
135 |
136 | HStack {
137 | Spacer()
138 |
139 | Button("Cancel", role: .cancel) {
140 | dismiss()
141 | }
142 | .focusable()
143 | .keyboardShortcut(.escape)
144 |
145 | Button("Save") {
146 | deepLinks.edit(deepLink, name: name, url: url)
147 | dismiss()
148 | }
149 | .focusable()
150 | .keyboardShortcut(.return)
151 | }
152 | }
153 | .onAppear {
154 | if let link = deepLinks.link(deepLink) {
155 | self.name = link.name
156 | self.url = link.url.absoluteString
157 | }
158 | }
159 | .padding()
160 | }
161 | }
162 | }
163 |
164 | struct DeepLinkEditorView_Previews: PreviewProvider {
165 | static var previews: some View {
166 | DeepLinkEditorView()
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/ControlRoom/Simulator UI/ControlView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ControlView.swift
3 | // ControlRoom
4 | //
5 | // Created by Paul Hudson on 12/02/2020.
6 | // Copyright © 2020 Paul Hudson. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | /// The main tab view to control simulator settings.
12 | struct ControlView: View {
13 | /// Used to handle creating screenshots, videos, and GIFs.
14 | @StateObject private var captureController = CaptureController()
15 |
16 | /// Let's us watch the list of active simulators.
17 | @ObservedObject var controller: SimulatorsController
18 |
19 | let simulator: Simulator
20 | let applications: [Application]
21 |
22 | var body: some View {
23 | TabView {
24 | SystemView(simulator: simulator)
25 | .disabled(simulator.state != .booted)
26 | SnapshotsView(simulator: simulator, controller: controller)
27 | Group {
28 | AppView(simulator: simulator, applications: applications)
29 | LocationView(controller: controller, simulator: simulator)
30 | StatusBarView(simulator: simulator)
31 | OverridesView(simulator: simulator)
32 | ColorsView()
33 | }
34 | .disabled(simulator.state != .booted)
35 |
36 | }
37 | .navigationSubtitle("\(simulator.name) – \(simulator.runtime?.name ?? "Unknown OS")")
38 | .toolbar {
39 | Menu("Save \(captureController.imageFormatString)") {
40 | Button("Save as PNG") {
41 | captureController.takeScreenshot(of: simulator, format: .png)
42 | }
43 |
44 | Button("Save as JPEG") {
45 | captureController.takeScreenshot(of: simulator, format: .jpeg)
46 | }
47 |
48 | Button("Save as TIFF") {
49 | captureController.takeScreenshot(of: simulator, format: .tiff)
50 | }
51 |
52 | Button("Save as BMP") {
53 | captureController.takeScreenshot(of: simulator, format: .bmp)
54 | }
55 | } primaryAction: {
56 | captureController.takeScreenshot(of: simulator)
57 | }
58 |
59 | if captureController.recordingProcess == nil {
60 | Menu("Record \(captureController.videoFormatString)") {
61 | ForEach(SimCtl.IO.VideoFormat.all, id: \.self) { item in
62 | if item == .divider {
63 | Divider()
64 | } else {
65 | Button("Save as \(item.name)") {
66 | captureController.startRecordingVideo(of: simulator, format: item)
67 | }
68 | }
69 | }
70 | } primaryAction: {
71 | captureController.startRecordingVideo(of: simulator)
72 | }
73 | } else {
74 | Button("Stop Recording", action: captureController.stopRecordingVideo)
75 | }
76 |
77 | if simulator.state != .booted {
78 | Button("Boot", action: bootDevice)
79 | }
80 |
81 | if simulator.state != .shutdown {
82 | Button("Shutdown", action: shutdownDevice)
83 | }
84 | }
85 | }
86 |
87 | /// Launches the current device.
88 | func bootDevice() {
89 | SimCtl.boot(simulator)
90 | }
91 |
92 | /// Terminates the current device.
93 | func shutdownDevice() {
94 | SimCtl.shutdown(simulator.udid)
95 | }
96 | }
97 |
98 | struct ControlView_Previews: PreviewProvider {
99 | static var previews: some View {
100 | ControlView(controller: .init(preferences: .init()),
101 | simulator: .example,
102 | applications: [])
103 | .environmentObject(Preferences())
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/ControlRoom/ar.lproj/Main.strings:
--------------------------------------------------------------------------------
1 |
2 | /* Class = "NSMenuItem"; title = "ControlRoom"; ObjectID = "1Xt-HY-uBw"; */
3 | "1Xt-HY-uBw.title" = "ControlRoom";
4 |
5 | /* Class = "NSMenuItem"; title = "Delete Selected Simulators..."; ObjectID = "23s-gN-NCt"; */
6 | "23s-gN-NCt.title" = "Delete Selected Simulators...";
7 |
8 | /* Class = "NSMenuItem"; title = "Enter Full Screen"; ObjectID = "4J7-dP-txa"; */
9 | "4J7-dP-txa.title" = "Enter Full Screen";
10 |
11 | /* Class = "NSMenuItem"; title = "Quit ControlRoom"; ObjectID = "4sb-4s-VLi"; */
12 | "4sb-4s-VLi.title" = "Quit ControlRoom";
13 |
14 | /* Class = "NSMenuItem"; title = "Edit"; ObjectID = "5QF-Oa-p0T"; */
15 | "5QF-Oa-p0T.title" = "Edit";
16 |
17 | /* Class = "NSMenuItem"; title = "About Control Room"; ObjectID = "5kV-Vb-QxS"; */
18 | "5kV-Vb-QxS.title" = "About Control Room";
19 |
20 | /* Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6"; */
21 | "AYu-sK-qS6.title" = "Main Menu";
22 |
23 | /* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */
24 | "BOF-NM-1cW.title" = "Preferences…";
25 |
26 | /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */
27 | "DVo-aG-piG.title" = "Close";
28 |
29 | /* Class = "NSMenu"; title = "Help"; ObjectID = "F2S-fz-NVQ"; */
30 | "F2S-fz-NVQ.title" = "Help";
31 |
32 | /* Class = "NSMenuItem"; title = "Project GitHub"; ObjectID = "FKE-Sm-Kum"; */
33 | "FKE-Sm-Kum.title" = "Project GitHub";
34 |
35 | /* Class = "NSMenuItem"; title = "View"; ObjectID = "H8h-7b-M4v"; */
36 | "H8h-7b-M4v.title" = "View";
37 |
38 | /* Class = "NSMenu"; title = "View"; ObjectID = "HyV-fh-RgO"; */
39 | "HyV-fh-RgO.title" = "View";
40 |
41 | /* Class = "NSMenuItem"; title = "Undo"; ObjectID = "IA0-pV-GaV"; */
42 | "IA0-pV-GaV.title" = "Undo";
43 |
44 | /* Class = "NSMenuItem"; title = "Show All"; ObjectID = "Kd2-mp-pUS"; */
45 | "Kd2-mp-pUS.title" = "Show All";
46 |
47 | /* Class = "NSMenuItem"; title = "Bring All to Front"; ObjectID = "LE2-aR-0XJ"; */
48 | "LE2-aR-0XJ.title" = "Bring All to Front";
49 |
50 | /* Class = "NSMenuItem"; title = "Services"; ObjectID = "NMo-om-nkz"; */
51 | "NMo-om-nkz.title" = "Services";
52 |
53 | /* Class = "NSMenuItem"; title = "Minimize"; ObjectID = "OY7-WF-poV"; */
54 | "OY7-WF-poV.title" = "Minimize";
55 |
56 | /* Class = "NSMenuItem"; title = "Hide ControlRoom"; ObjectID = "Olw-nP-bQN"; */
57 | "Olw-nP-bQN.title" = "Hide ControlRoom";
58 |
59 | /* Class = "NSMenuItem"; title = "Zoom"; ObjectID = "R4o-n2-Eq4"; */
60 | "R4o-n2-Eq4.title" = "Zoom";
61 |
62 | /* Class = "NSMenuItem"; title = "Select All"; ObjectID = "Ruw-6m-B2m"; */
63 | "Ruw-6m-B2m.title" = "Select All";
64 |
65 | /* Class = "NSMenu"; title = "Window"; ObjectID = "Td7-aD-5lo"; */
66 | "Td7-aD-5lo.title" = "Window";
67 |
68 | /* Class = "NSMenuItem"; title = "Stay In Front"; ObjectID = "Tzd-5z-P3W"; */
69 | "Tzd-5z-P3W.title" = "Stay In Front";
70 |
71 | /* Class = "NSMenuItem"; title = "Hide Others"; ObjectID = "Vdr-fp-XzO"; */
72 | "Vdr-fp-XzO.title" = "Hide Others";
73 |
74 | /* Class = "NSMenu"; title = "Edit"; ObjectID = "W48-6f-4Dl"; */
75 | "W48-6f-4Dl.title" = "Edit";
76 |
77 | /* Class = "NSMenuItem"; title = "New Simulator..."; ObjectID = "WUk-cr-sBl"; */
78 | "WUk-cr-sBl.title" = "New Simulator...";
79 |
80 | /* Class = "NSMenuItem"; title = "Window"; ObjectID = "aUF-d1-5bR"; */
81 | "aUF-d1-5bR.title" = "Window";
82 |
83 | /* Class = "NSMenu"; title = "File"; ObjectID = "bib-Uj-vzu"; */
84 | "bib-Uj-vzu.title" = "File";
85 |
86 | /* Class = "NSMenuItem"; title = "Copy"; ObjectID = "crV-ox-do9"; */
87 | "crV-ox-do9.title" = "Copy";
88 |
89 | /* Class = "NSMenuItem"; title = "File"; ObjectID = "dMs-cI-mzQ"; */
90 | "dMs-cI-mzQ.title" = "File";
91 |
92 | /* Class = "NSMenuItem"; title = "Paste"; ObjectID = "f5u-Np-Ehc"; */
93 | "f5u-Np-Ehc.title" = "Paste";
94 |
95 | /* Class = "NSMenuItem"; title = "Cut"; ObjectID = "fuj-su-hDM"; */
96 | "fuj-su-hDM.title" = "Cut";
97 |
98 | /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */
99 | "hz9-B4-Xy5.title" = "Services";
100 |
101 | /* Class = "NSMenuItem"; title = "Show Sidebar"; ObjectID = "kIP-vf-haE"; */
102 | "kIP-vf-haE.title" = "Show Sidebar";
103 |
104 | /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "pSm-M9-WHH"; */
105 | "pSm-M9-WHH.title" = "Redo";
106 |
107 | /* Class = "NSMenuItem"; title = "Delete Unavailable Simulators..."; ObjectID = "tVk-RZ-EV2"; */
108 | "tVk-RZ-EV2.title" = "Delete Unavailable Simulators...";
109 |
110 | /* Class = "NSMenu"; title = "ControlRoom"; ObjectID = "uQy-DD-JDr"; */
111 | "uQy-DD-JDr.title" = "ControlRoom";
112 |
113 | /* Class = "NSMenuItem"; title = "Help"; ObjectID = "wpr-3q-Mcd"; */
114 | "wpr-3q-Mcd.title" = "Help";
115 |
--------------------------------------------------------------------------------
/ControlRoomTests/Controllers/SimCtl+SubCommandsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ControlRoomTests.swift
3 | // ControlRoomTests
4 | //
5 | // Created by Patrick Luddy on 2/16/20.
6 | // Copyright © 2020 Paul Hudson. All rights reserved.
7 | //
8 |
9 | @testable import Control_Room
10 | import XCTest
11 |
12 | class SimCtlSubCommandsTests: XCTestCase {
13 |
14 | func testDeleteUnavailable() throws {
15 | let command: SimCtl.Command = .delete(.unavailable)
16 | let expectation = ["simctl", "delete", "unavailable"]
17 | XCTAssertEqual(command.arguments, expectation)
18 | }
19 |
20 | func testBoot() throws {
21 | let getSimulator: (String) -> Simulator = { buildVersion in
22 | let runtime = Runtime(buildversion: buildVersion, identifier: "made-up", version: "version", isAvailable: true, name: "iPhone 14")
23 | return Simulator(name: "iPhone 14", udid: "made-up-udid", state: .shutdown, runtime: runtime, deviceType: nil, dataPath: "fake-path")
24 | }
25 | let expectedArguments = ["simctl", "boot", "made-up-udid"]
26 |
27 | let command160: SimCtl.Command = .boot(simulator: getSimulator("16.0"))
28 | XCTAssertEqual(command160.arguments, expectedArguments)
29 | XCTAssertEqual(command160.environmentOverrides, nil)
30 |
31 | let command161: SimCtl.Command = .boot(simulator: getSimulator("16.1"))
32 | XCTAssertEqual(command161.arguments, expectedArguments)
33 | XCTAssertEqual(command161.environmentOverrides, ["SIMCTL_CHILD_SIMULATOR_RUNTIME_VERSION": "16.0"])
34 | }
35 |
36 | func testRecordAVideo() throws {
37 | let command: SimCtl.Command = .io(deviceId: "device1", operation: .recordVideo(codec: .h264, url: "~/my-video.mov"))
38 | let expectation = ["simctl", "io", "device1", "recordVideo", "--codec=h264", "~/my-video.mov"]
39 | XCTAssertEqual(command.arguments, expectation)
40 | }
41 |
42 | func testScreenshot() throws {
43 | let command: SimCtl.Command = .io(deviceId: "device1", operation: .screenshot(type: .png, display: .internal, mask: .ignored, url: "~/my-image.png"))
44 | let expectation = ["simctl", "io", "device1", "screenshot", "--type=png", "--display=internal", "--mask=ignored", "~/my-image.png"]
45 | XCTAssertEqual(command.arguments, expectation)
46 | }
47 |
48 | func testlist() throws {
49 | let command: SimCtl.Command = .list()
50 | let expectation = ["simctl", "list"]
51 | XCTAssertEqual(command.arguments, expectation)
52 | }
53 |
54 | func testlistFilterSearchFlag() throws {
55 | let command: SimCtl.Command = .list(filter: .devicetypes, search: .string("search"), flags: [.json])
56 | let expectation = ["simctl", "list", "devicetypes", "search", "-j"]
57 | XCTAssertEqual(command.arguments, expectation)
58 | }
59 |
60 | func testOpenUrl() throws {
61 | let command: SimCtl.Command = .openURL(deviceId: "device1", url: "https://www.hackingwithswift.com")
62 | let expectation = ["simctl", "openurl", "device1", "https://www.hackingwithswift.com"]
63 | XCTAssertEqual(command.arguments, expectation)
64 | }
65 |
66 | func testAddMedia() throws {
67 | let command: SimCtl.Command = .addMedia(deviceId: "device1", mediaPaths: ["~/sample-1.jpg"])
68 | let expectation = ["simctl", "addmedia", "device1", "~/sample-1.jpg"]
69 | XCTAssertEqual(command.arguments, expectation)
70 | }
71 |
72 | func testDefaultsForApp() throws {
73 | let command: SimCtl.Command = .spawn(deviceId: "device1", pathToExecutable: "defaults read", options: [.waitForDebugger])
74 | let expectation = ["simctl", "spawn", "-w", "device1", "defaults read"]
75 | XCTAssertEqual(command.arguments, expectation)
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/ControlRoomTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Paul Hudson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Control Room is a macOS app that lets you control the simulators for iOS, tvOS, and watchOS – their UI appearance, status bar configuration, and more. It wraps Apple’s own **simctl** command-line tool, so you’ll need Xcode installed.
14 |
15 | You’ll need Xcode 14.0 or later to build and use Control Room on your Mac.
16 |
17 |
18 | ## Installation
19 |
20 | To try Control Room yourself, download the code and build it through Xcode. It’s built using SwiftUI, so you’ll need macOS Big Sur in order to run it. You will also need Xcode installed, because it relies on the **simctl** command being present – if you see an error that you’re missing the command line tools, go to Xcode's Preferences, choose the Locations tab, then make sure Xcode is selected for Command Line Tools.
21 |
22 |
23 | ## Features
24 |
25 | Control Room is packed with features to help you develop apps more effectively, including:
26 |
27 | - Taking screenshots and movies, optionally adding the device bezels to your screenshots.
28 | - Adjusting the system time and date to whatever you want, including Apple’s preferred 9:41.
29 | - Controlling status of WiFi, cellular service, and battery.
30 | - Opening the data folder for your app, or editing your `UserDefaults` entries.
31 | - Overriding dark or light mode, language, accessibility options, and Dynamic Type content size.
32 | - Picking a custom user location from anywhere in the world.
33 | - Starting, stopping, installing, and removing apps.
34 | - Sending test push notifications or triggering deep links.
35 | - Selecting colors from the simulator, converting them to UIKit or SwiftUI code, or even dragging directly into your asset catalog.
36 |
37 | Plus there’s an optional menu bar icon adding quick actions such as re-sending the last push notification or re-opening your last deep link.
38 |
39 |
40 |
41 | ## Contribution guide
42 |
43 | Any help you can offer with this project is most welcome – there are opportunities big and small so that someone with only a small amount of Swift experience can help.
44 |
45 | Some suggestions you might want to explore:
46 |
47 | - Handle errors in a meaningful way.
48 | - Add documentation in the code or here in the README.
49 | - Did I mention handling errors in a meaningful way?
50 |
51 | You’re also welcome to try adding some tests, although given our underlying use of simctl that might be tricky.
52 |
53 | If you spot any errors please open an issue and let us know which macOS and Xcode versions you’re using.
54 |
55 | **Please ensure that SwiftLint returns no errors or warnings before you send in changes.**
56 |
57 |
58 | ## Credits
59 |
60 | Control Room was originally designed and built by Paul Hudson, and is copyright © Paul Hudson 2023. The icon was designed by Raphael Lopes.
61 |
62 | Control Room is licensed under the MIT license; for the full license please see the [LICENSE file](LICENSE). Many other folks have contributed features, fixes, and more to make Control Room what it is today. Control Room is built on top of Apple’s **simctl** command – the team who built that deserve the real credit here.
63 |
64 | Swift, the Swift logo, and Xcode are trademarks of Apple Inc., registered in the U.S. and other countries.
65 |
66 | If you find Control Room useful, you might find my website full of Swift tutorials equally useful: [Hacking with Swift](https://www.hackingwithswift.com).
67 |
--------------------------------------------------------------------------------
/Working/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/Working/logo.png
--------------------------------------------------------------------------------
/Working/logo.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/Working/logo.psd
--------------------------------------------------------------------------------
/Working/logo.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/twostraws/ControlRoom/327e37e8f2fe65ab3f5c8051f2312c66b58a0b1c/Working/logo.sketch
--------------------------------------------------------------------------------