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
struct SimApp: Equatable {
class SimApp: ObservableObject {
@Published var state = State.unknown
@Published var pid : Int?
weak var device : SimDevice?
let identifier : String
let bundleID : String
let bundleName : String
@ -18,12 +22,13 @@ struct SimApp: Equatable {
let sandboxPath : String?
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 bundleID = infoPList[kCFBundleIdentifierKey as String] as? String else { throw SimError.invalidApp }
self.bundlePath = bundlePath.path
self.bundleID = bundleID
self.device = device
bundleName = (infoPList[kCFBundleNameKey as String] as? String) ?? "<missing>"
displayName = (infoPList["CFBundleDisplayName"] as? String) ?? bundleName
version = (infoPList["CFBundleShortVersionString"] as? String) ?? "<missing>"
@ -69,6 +74,49 @@ struct SimApp: Equatable {
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 {

View file

@ -133,4 +133,46 @@ struct SimCtl {
func saveVideo(_ device: SimDevice, url: URL) throws -> Process {
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 {
if NSWorkspace.shared.isFilePackage(atPath: testDir.path) {
do {
apps.append(try SimApp(bundlePath: testDir, sandboxPaths: sandboxPaths))
apps.append(try SimApp(bundlePath: testDir, sandboxPaths: sandboxPaths, device: self))
}
catch {
print("Failed to instantiate SimApp at \(testDir.path)")

View file

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

View file

@ -9,25 +9,47 @@ import SwiftUI
extension SimApp {
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 {
var app : SimApp
@ObservedObject var app : SimApp
var body: some View {
VStack(alignment: .leading, spacing: 0.0) {
ContentHeader("Paths")
Group {
PathRow(title: "Bundle Path", path: app.bundlePath)
if let sandboxPath = app.sandboxPath {
PathRow(title: "Sandbox Path", path: sandboxPath)
}
else {
Text("Sandbox Path: <unknown>")
}
}
.font(.subheadline)
.lineLimit(1)
PathRow(title: "Bundle Path", path: app.bundlePath)
if let sandboxPath = app.sandboxPath {
PathRow(title: "Sandbox Path", path: sandboxPath)
}
else {
Text("Sandbox Path: <unknown>")
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()
}
.font(.subheadline)
.lineLimit(1)
}
}

View file

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

View file

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

View file

@ -14,6 +14,7 @@ extension ButtonStyle where Self == SystemIconButtonStyle {
}
struct SystemIconButtonStyle: ButtonStyle {
@Environment(\.isEnabled) var isEnabled
@State var isFocused = false
let isActive : Bool
let imageName : String
@ -41,15 +42,15 @@ struct SystemIconButtonStyle: ButtonStyle {
.foregroundColor(foregroundColor(pressed: configuration.isPressed))
}
.padding(1.0)
.onHover { isFocused = $0 }
.onHover { isFocused = $0 && isEnabled }
}
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 {
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))
}
}