Now we can launch / terminate apps.

This commit is contained in:
Casey Fleser 2022-08-28 07:06:55 -05:00
parent 762f0b7bc0
commit 018ed0eecc
8 changed files with 146 additions and 21 deletions

View file

@ -7,7 +7,11 @@
import SwiftUI import SwiftUI
struct SimApp: Equatable { class SimApp: ObservableObject {
@Published var state = State.unknown
@Published var pid : Int?
weak var device : SimDevice?
let identifier : String let identifier : String
let bundleID : String let bundleID : String
let bundleName : String let bundleName : String
@ -18,12 +22,13 @@ struct SimApp: Equatable {
let sandboxPath : String? let sandboxPath : String?
let nsIcon : NSImage? let nsIcon : NSImage?
init(bundlePath: URL, sandboxPaths: [String : URL]) throws { init(bundlePath: URL, sandboxPaths: [String : URL], device: SimDevice) throws {
guard let infoPList = PropertyListSerialization.propertyList(from: bundlePath.appendingPathComponent("Info.plist")) else { throw SimError.invalidApp } guard let infoPList = PropertyListSerialization.propertyList(from: bundlePath.appendingPathComponent("Info.plist")) else { throw SimError.invalidApp }
guard let bundleID = infoPList[kCFBundleIdentifierKey as String] as? String else { throw SimError.invalidApp } guard let bundleID = infoPList[kCFBundleIdentifierKey as String] as? String else { throw SimError.invalidApp }
self.bundlePath = bundlePath.path self.bundlePath = bundlePath.path
self.bundleID = bundleID self.bundleID = bundleID
self.device = device
bundleName = (infoPList[kCFBundleNameKey as String] as? String) ?? "<missing>" bundleName = (infoPList[kCFBundleNameKey as String] as? String) ?? "<missing>"
displayName = (infoPList["CFBundleDisplayName"] as? String) ?? bundleName displayName = (infoPList["CFBundleDisplayName"] as? String) ?? bundleName
version = (infoPList["CFBundleShortVersionString"] as? String) ?? "<missing>" version = (infoPList["CFBundleShortVersionString"] as? String) ?? "<missing>"
@ -69,6 +74,49 @@ struct SimApp: Equatable {
return validIcon ? icon : nil return validIcon ? icon : nil
} }
} }
func discoverState() {
Task {
let result = try await SimCtl().getAppPID(self)
await MainActor.run {
pid = result
state = pid != nil ? .launched : .terminated
}
}
}
func toggleLaunchState() {
Task {
switch state {
case .launched:
try SimCtl().terminate(self)
await MainActor.run {
pid = nil
state = .terminated
}
default:
let result = try await SimCtl().launch(self)
await MainActor.run {
pid = result
state = pid != nil ? .launched : .terminated
}
}
}
}
}
extension SimApp {
enum State: String {
case terminated = "terminated"
case launched = "launched"
case unknown = "unknown"
var isOn : Bool { self == .launched }
}
} }
extension SimApp: SourceItemData { extension SimApp: SourceItemData {

View file

@ -133,4 +133,46 @@ struct SimCtl {
func saveVideo(_ device: SimDevice, url: URL) throws -> Process { func saveVideo(_ device: SimDevice, url: URL) throws -> Process {
return try run(args: ["io", device.udid, "recordVideo", "--force", url.path]) return try run(args: ["io", device.udid, "recordVideo", "--force", url.path])
} }
func getAppPID(_ app: SimApp) async throws -> Int? {
guard let device = app.device else { return nil }
let list : String = try await runAsync(args: ["spawn", device.udid, "launchctl", "list"])
let regex = try NSRegularExpression(pattern: "(?<PID>[0-9]+).*\(app.bundleID)")
let nsRange = NSRange(location: 0, length: (list as NSString).length)
var pid : Int? = nil
if let match = regex.firstMatch(in: list, range: nsRange) {
let range = match.range(withName: "PID")
if range.location != NSNotFound {
pid = Int((list as NSString).substring(with: range))
}
}
return pid
}
func launch(_ app: SimApp) async throws -> Int? {
guard let device = app.device else { return nil }
let output : String = try await runAsync(args: ["launch", device.udid, app.bundleID])
let regex = try NSRegularExpression(pattern: ".*: (?<PID>[0-9]+)")
let nsRange = NSRange(location: 0, length: (output as NSString).length)
var pid : Int? = nil
if let match = regex.firstMatch(in: output, range: nsRange) {
let range = match.range(withName: "PID")
if range.location != NSNotFound {
pid = Int((output as NSString).substring(with: range))
}
}
return pid
}
func terminate(_ app: SimApp) throws {
guard let device = app.device else { return }
try runAsync(args: ["terminate", device.udid, app.bundleID])
}
} }

View file

@ -92,7 +92,7 @@ class SimDevice: ObservableObject, Decodable {
for testDir in testDirs { for testDir in testDirs {
if NSWorkspace.shared.isFilePackage(atPath: testDir.path) { if NSWorkspace.shared.isFilePackage(atPath: testDir.path) {
do { do {
apps.append(try SimApp(bundlePath: testDir, sandboxPaths: sandboxPaths)) apps.append(try SimApp(bundlePath: testDir, sandboxPaths: sandboxPaths, device: self))
} }
catch { catch {
print("Failed to instantiate SimApp at \(testDir.path)") print("Failed to instantiate SimApp at \(testDir.path)")

View file

@ -21,15 +21,17 @@ extension ToggleDescriptor {
struct DescriptiveToggle<T: ToggleDescriptor>: View { struct DescriptiveToggle<T: ToggleDescriptor>: View {
@Binding var isOn : Bool @Binding var isOn : Bool
var descriptor : T var descriptor : T
var subtitled : Bool
init(_ descriptor: T, isOn: Binding<Bool>) { init(_ descriptor: T, isOn: Binding<Bool>, subtitled: Bool = true) {
self._isOn = isOn self._isOn = isOn
self.descriptor = descriptor self.descriptor = descriptor
self.subtitled = subtitled
} }
var body: some View { var body: some View {
Toggle(descriptor.titleKey, isOn: _isOn) Toggle(descriptor.titleKey, isOn: _isOn)
.toggleStyle(DescriptiveToggleStyle(descriptor)) .toggleStyle(DescriptiveToggleStyle(descriptor, subtitled: subtitled))
} }
} }

View file

@ -9,15 +9,26 @@ import SwiftUI
extension SimApp { extension SimApp {
public var content : some View { AppContent(app: self) } public var content : some View { AppContent(app: self) }
var isLaunched : Bool {
get { state.isOn }
set { toggleLaunchState() }
}
}
extension SimApp.State: ToggleDescriptor {
var titleKey : LocalizedStringKey { isOn ? "Terminate" : "Launch" }
var text : String { isOn ? "Launched" : "Terminated" }
var image : Image { Image(systemName: "power.circle") }
} }
struct AppContent: View { struct AppContent: View {
var app : SimApp @ObservedObject var app : SimApp
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0.0) { VStack(alignment: .leading, spacing: 0.0) {
ContentHeader("Paths") ContentHeader("Paths")
Group {
PathRow(title: "Bundle Path", path: app.bundlePath) PathRow(title: "Bundle Path", path: app.bundlePath)
if let sandboxPath = app.sandboxPath { if let sandboxPath = app.sandboxPath {
PathRow(title: "Sandbox Path", path: sandboxPath) PathRow(title: "Sandbox Path", path: sandboxPath)
@ -28,6 +39,17 @@ struct AppContent: View {
} }
.font(.subheadline) .font(.subheadline)
.lineLimit(1) .lineLimit(1)
ContentHeader("Actions")
HStack(spacing: 16) {
DescriptiveToggle(app.state, isOn: $app.isLaunched, subtitled: false)
.frame(width: 58)
}
.environment(\.isEnabled, app.device?.isBooted == true)
}
.onAppear {
app.discoverState()
}
} }
} }

View file

@ -23,6 +23,7 @@ struct AppHeader: View {
Text("Minimum OS Version: \(app.minOSVersion)") Text("Minimum OS Version: \(app.minOSVersion)")
} }
.font(.subheadline) .font(.subheadline)
.textSelection(.enabled)
} }
} }

View file

@ -8,10 +8,15 @@
import SwiftUI import SwiftUI
struct DescriptiveToggleStyle<T: ToggleDescriptor>: ToggleStyle { struct DescriptiveToggleStyle<T: ToggleDescriptor>: ToggleStyle {
var descriptor : T @Environment(\.isEnabled) var isEnabled
@State var isFocused = false
init(_ descriptor: T) { var descriptor : T
var subtitled : Bool
init(_ descriptor: T, subtitled: Bool = true) {
self.descriptor = descriptor self.descriptor = descriptor
self.subtitled = subtitled
} }
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
@ -19,7 +24,7 @@ struct DescriptiveToggleStyle<T: ToggleDescriptor>: ToggleStyle {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack { ZStack {
Circle() Circle()
.foregroundColor(descriptor.circleColor) .foregroundColor(descriptor.circleColor.opacity(isFocused ? 1.0 : 0.9))
descriptor.image descriptor.image
.resizable() .resizable()
.foregroundColor(descriptor.isOn ? .white : Color("CircleSymbolOff")) .foregroundColor(descriptor.isOn ? .white : Color("CircleSymbolOff"))
@ -32,14 +37,18 @@ struct DescriptiveToggleStyle<T: ToggleDescriptor>: ToggleStyle {
Group { Group {
Text(descriptor.titleKey) Text(descriptor.titleKey)
.fontWeight(.semibold) .fontWeight(.semibold)
if subtitled {
Text(descriptor.text) Text(descriptor.text)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
}
.font(.system(size: 11)) .font(.system(size: 11))
.allowsTightening(true) .allowsTightening(true)
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.onHover { isFocused = $0 && isEnabled }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View file

@ -14,6 +14,7 @@ extension ButtonStyle where Self == SystemIconButtonStyle {
} }
struct SystemIconButtonStyle: ButtonStyle { struct SystemIconButtonStyle: ButtonStyle {
@Environment(\.isEnabled) var isEnabled
@State var isFocused = false @State var isFocused = false
let isActive : Bool let isActive : Bool
let imageName : String let imageName : String
@ -41,15 +42,15 @@ struct SystemIconButtonStyle: ButtonStyle {
.foregroundColor(foregroundColor(pressed: configuration.isPressed)) .foregroundColor(foregroundColor(pressed: configuration.isPressed))
} }
.padding(1.0) .padding(1.0)
.onHover { isFocused = $0 } .onHover { isFocused = $0 && isEnabled }
} }
func foregroundColor(pressed: Bool) -> Color { func foregroundColor(pressed: Bool) -> Color {
return .primary.opacity(pressed ? 1.0 : 0.6) return .primary.opacity(pressed ? 1.0 : (isEnabled ? 0.7 : 0.6))
} }
func backgroundColor(pressed: Bool) -> Color { func backgroundColor(pressed: Bool) -> Color {
return .primary.opacity(0.05 + (isFocused ? 0.1 : 0.0) + (pressed ? 0.1 : 0.0)) return .primary.opacity((isEnabled ? 0.1 : 0.05) + (isFocused ? 0.1 : 0.0) + (pressed ? 0.1 : 0.0))
} }
} }