Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions assets/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,22 @@
}
}
},
"The simulator application could not be found. Check your Xcode installation.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The simulator application could not be found. Check your Xcode installation."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "未找到模拟器程序,请检查 Xcode 安装。"
}
}
}
},
"The simulator returned an invalid app list.": {
"localizations": {
"en": {
Expand Down
19 changes: 11 additions & 8 deletions src/AppInstallButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct AppInstallButton: View {
let store: AppInstallationStore
let isInstalled: Bool?
var allowsIncompatible = false
var isDefaultAction = false

private enum Action: CaseIterable {
case install, open, cancel, installing, canceling, opening
Expand Down Expand Up @@ -44,6 +45,14 @@ struct AppInstallButton: View {
}

var body: some View {
if phase == nil {
button.buttonStyle(.borderedProminent)
} else {
button.buttonStyle(.bordered)
}
}

private var button: some View {
Button {
switch action {
case .install: store.install(app: app, version: version, on: device)
Expand All @@ -58,6 +67,7 @@ struct AppInstallButton: View {
.lineLimit(1)
}
.disabled(!enabled)
.keyboardShortcut(isDefaultAction && phase == nil ? .defaultAction : nil)
.accessibilityLabel(Text(verbatim: "\(action.title) \(app.name)"))
.accessibilityValue(Text(verbatim: phase?.summary(totalBytes: version.size) ?? ""))
.help(Text(verbatim: version.minimumOSLabel))
Expand All @@ -74,14 +84,7 @@ struct AppInstallStatusView: View {
VStack(alignment: .leading, spacing: 10) {
if let phase = store.phase(appID: appID, deviceID: deviceID) {
if case .downloading(let bytes) = phase {
ProgressView(value: Double(min(bytes, version.size)), total: Double(version.size)) {
Text(verbatim: phase.title)
}
Text(
verbatim:
"\(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)) / \(ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file))"
)
.font(.system(size: 11)).monospacedDigit().foregroundStyle(.secondary)
DownloadProgressView(receivedBytes: bytes, totalBytes: version.size, title: phase.title)
} else {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Expand Down
13 changes: 12 additions & 1 deletion src/AppInstallationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,24 @@ final class AppInstallationStore {

private var phases: [Key: AppInstallationPhase] = [:]
private var errors: [Key: String] = [:]
private(set) var openedApp: Key?
@ObservationIgnored private let installer: AppInstaller
@ObservationIgnored private let didInstall: @MainActor (String, String) -> Void
@ObservationIgnored private let showDevice: @MainActor (SimulatorDevice) async throws -> Void
@ObservationIgnored private let didOpen: @MainActor (String, String) -> Void
@ObservationIgnored private var jobs: [Key: Task<Void, Never>] = [:]
@ObservationIgnored private var requests: [Key: UUID] = [:]

init(
installer: AppInstaller = AppInstaller(),
didInstall: @escaping @MainActor (String, String) -> Void = { _, _ in }
showDevice: @escaping @MainActor (SimulatorDevice) async throws -> Void,
didInstall: @escaping @MainActor (String, String) -> Void = { _, _ in },
didOpen: @escaping @MainActor (String, String) -> Void = { _, _ in }
) {
self.installer = installer
self.didInstall = didInstall
self.showDevice = showDevice
self.didOpen = didOpen
}

func phase(appID: String, deviceID: String) -> AppInstallationPhase? {
Expand Down Expand Up @@ -70,12 +77,16 @@ final class AppInstallationStore {
func open(app: CatalogApp, on device: SimulatorDevice) {
let key = Key(appID: app.id, deviceID: device.id)
guard jobs[key] == nil else { return }
openedApp = nil
errors[key] = nil
phases[key] = .opening
jobs[key] = Task {
defer { finish(key) }
do {
try await installer.open(app: app, on: device)
try await showDevice(device)
didOpen(app.bundleID, device.id)
openedApp = key
} catch {
errors[key] = error.localizedDescription
}
Expand Down
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ add_executable(SimStore MACOSX_BUNDLE
SimStoreApp.swift
SimulatorDiscovery.swift
SimulatorControl.swift
SimulatorPresenter.swift
SimulatorApplications.swift
InstalledAppsStore.swift
InstalledAppsStatusView.swift
Expand All @@ -19,6 +20,7 @@ add_executable(SimStore MACOSX_BUNDLE
AppInstaller.swift
AppInstallationStore.swift
AppInstallButton.swift
DownloadProgressView.swift
CatalogClient.swift
CatalogStore.swift
CatalogPresentation.swift
Expand Down
1 change: 0 additions & 1 deletion src/CatalogAppCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ struct CatalogAppCard: View {
app: app, version: version, device: device, store: installations,
isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id)
)
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityIdentifier("catalog-install-\(app.id)")
}
Expand Down
35 changes: 30 additions & 5 deletions src/CatalogAppDetailView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

@MainActor
Expand All @@ -7,6 +8,8 @@ struct CatalogAppDetailView: View {
let installedApps: InstalledAppsStore
let installations: AppInstallationStore
@State private var versionID: String
@State private var showsBuildDetails = false
@State private var collapsedContentHeight: CGFloat = 560
@Environment(\.dismiss) private var dismiss

init(
Expand All @@ -26,6 +29,13 @@ struct CatalogAppDetailView: View {
app.versions.first(where: { $0.version == versionID }) ?? app.recommendedVersion(for: runtime)
}

private var contentHeight: CGFloat {
let screen = NSApp.keyWindow?.screen ?? NSApp.mainWindow?.screen ?? NSScreen.main
// Reserve space for the footer, window chrome, and screen edges.
let available = (screen?.visibleFrame.height ?? 900) - 160
return min(collapsedContentHeight.rounded(.up), available)
}

var body: some View {
VStack(spacing: 0) {
ScrollView {
Expand Down Expand Up @@ -113,7 +123,7 @@ struct CatalogAppDetailView: View {
}
}

DisclosureGroup("Build Details") {
DisclosureGroup("Build Details", isExpanded: $showsBuildDetails) {
VStack(alignment: .leading, spacing: 12) {
buildDetail(
"Source Revision", value: version.revision,
Expand All @@ -138,6 +148,15 @@ struct CatalogAppDetailView: View {
.fixedSize(horizontal: false, vertical: true)
}
.padding(28)
.background {
GeometryReader { geometry in
Color.clear.preference(key: DetailContentHeight.self, value: geometry.size.height)
}
}
}
.frame(height: contentHeight)
.onPreferenceChange(DetailContentHeight.self) { height in
if !showsBuildDetails && height > 0 { collapsedContentHeight = height }
}
Divider()
HStack {
Expand All @@ -146,15 +165,13 @@ struct CatalogAppDetailView: View {
AppInstallButton(
app: app, version: version, device: device, store: installations,
isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id),
allowsIncompatible: true
allowsIncompatible: true, isDefaultAction: true
)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("detail-install")
}
.padding(20)
}
.frame(width: 580, height: 560)
.frame(width: 580)
}

private func buildDetail(_ title: LocalizedStringKey, value: String, link: URL? = nil)
Expand All @@ -175,6 +192,14 @@ struct CatalogAppDetailView: View {
}
}

private struct DetailContentHeight: PreferenceKey {
static let defaultValue: CGFloat = 0

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}

private struct DetailsDisclosureStyle: DisclosureGroupStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 0) {
Expand Down
8 changes: 7 additions & 1 deletion src/CatalogBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct CatalogBrowserView: View {

private var availableApps: [CatalogApp]? {
guard device.state == "Booted", let catalog = store.catalog else { return nil }
return installedApps.uninstalledApps(in: catalog, for: device.id)
return installedApps.discoverableApps(in: catalog, for: device.id)
}

private var apps: [CatalogApp] {
Expand Down Expand Up @@ -81,6 +81,11 @@ struct CatalogBrowserView: View {
.padding(.top, 28)
.task { await store.refresh() }
.task(id: device) { await installedApps.refresh(for: device) }
.onChange(of: installations.openedApp) { _, opened in
if let opened, opened.deviceID == device.id, selectedApp?.id == opened.appID {
selectedApp = nil
}
}
.onChange(of: categories) { _, values in
guard availableApps != nil else { return }
if !category.isEmpty && !values.contains(category) { category = "" }
Expand Down Expand Up @@ -227,6 +232,7 @@ struct CatalogBrowserView: View {
private func refreshInstalledApps() { Task { await installedApps.refresh(for: device) } }

private func refresh() {
installedApps.clearRecentInstallations()
Task { await store.refresh() }
refreshInstalledApps()
}
Expand Down
15 changes: 14 additions & 1 deletion src/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,32 @@ struct ContentView: View {
},
catalogClient: CatalogClient = .live,
installer: AppInstaller = AppInstaller(),
showDevice: (@MainActor (SimulatorDevice) async throws -> Void)? = nil,
loadInstalledApps: @escaping @Sendable (SimulatorDevice) async throws -> Set<String> = {
try await SimulatorApplications.installedBundleIDs(on: $0)
}
) {
_simulators = State(initialValue: SimulatorStore(discover: discover, boot: boot))
let simulators = SimulatorStore(discover: discover, boot: boot)
_simulators = State(initialValue: simulators)
_catalog = State(initialValue: CatalogStore(client: catalogClient))
let installedApps = InstalledAppsStore(load: loadInstalledApps)
_installedApps = State(initialValue: installedApps)
_installations = State(
initialValue: AppInstallationStore(
installer: installer,
showDevice: { device in
if let showDevice {
try await showDevice(device)
} else {
try await SimulatorPresenter.show(
deviceID: device.id, manager: simulators.discovery?.deviceManager)
}
},
didInstall: { bundleID, deviceID in
installedApps.recordInstallation(bundleID: bundleID, deviceID: deviceID)
},
didOpen: { bundleID, deviceID in
installedApps.removeRecentInstallation(bundleID: bundleID, deviceID: deviceID)
}))
}

Expand Down
28 changes: 28 additions & 0 deletions src/DownloadProgressView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI

struct DownloadProgressView: View {
let receivedBytes: Int64
let totalBytes: Int64
let title: String

var body: some View {
VStack(alignment: .leading, spacing: 10) {
ProgressView(
value: Double(max(0, min(receivedBytes, totalBytes))), total: Double(totalBytes)
) {
Text(verbatim: title)
}
HStack(alignment: .firstTextBaseline) {
Text(verbatim: ByteCountFormatter.string(fromByteCount: receivedBytes, countStyle: .file))
.accessibilityIdentifier("download-received")
Spacer(minLength: 8)
Text(
verbatim: "/ \(ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file))"
)
.fixedSize()
.accessibilityIdentifier("download-total")
}
.font(.system(size: 11)).monospacedDigit().foregroundStyle(.secondary)
}
}
}
17 changes: 15 additions & 2 deletions src/InstalledAppsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class InstalledAppsStore {
private(set) var bundleIDs: Set<String>?
private(set) var isLoading = false
private(set) var error: String?
private var recentInstallations: Set<String> = []

@ObservationIgnored
private let load: @Sendable (SimulatorDevice) async throws -> Set<String>
Expand All @@ -26,15 +27,26 @@ final class InstalledAppsStore {
self.load = load
}

func uninstalledApps(in catalog: AppCatalog, for deviceID: String) -> [CatalogApp]? {
func discoverableApps(in catalog: AppCatalog, for deviceID: String) -> [CatalogApp]? {
guard self.deviceID == deviceID, let bundleIDs else { return nil }
let installed = Set(bundleIDs.map { $0.lowercased() })
return catalog.apps.filter { !installed.contains($0.bundleID.lowercased()) }
return catalog.apps.filter {
!installed.contains($0.bundleID.lowercased())
|| recentInstallations.contains($0.bundleID.lowercased())
}
}

func clearRecentInstallations() { recentInstallations = [] }

func removeRecentInstallation(bundleID: String, deviceID: String) {
guard self.deviceID == deviceID else { return }
recentInstallations.remove(bundleID.lowercased())
}

func recordInstallation(bundleID: String, deviceID: String) {
guard self.deviceID == deviceID else { return }
installedDuringRefresh.insert(bundleID)
recentInstallations.insert(bundleID.lowercased())
bundleIDs?.insert(bundleID)
}

Expand All @@ -53,6 +65,7 @@ final class InstalledAppsStore {
return
}
let pending = Request(device: device)
if selectedDevice?.id != device.id { clearRecentInstallations() }
request = pending
selectedDevice = device
deviceID = device.id
Expand Down
Loading