Skip to content

Commit 4c00a4a

Browse files
authored
Merge pull request #555 from insidegui/catalog-mobile-device-instructions
Make device support update requirement more prominent, improve instructions
2 parents 5f59de0 + 80a2806 commit 4c00a4a

File tree

7 files changed

+117
-34
lines changed

7 files changed

+117
-34
lines changed

VirtualBuddy/CommandLine/vctool/Core/Helpers.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@ extension ResolvedFeatureStatus {
114114
switch self {
115115
case .supported:
116116
return "✅ Supported"
117-
case .warning(let message):
117+
case .warning(_, let message):
118118
return "⚠️ Warning: \(message)"
119-
case .unsupported(let message):
119+
case .unsupported(_, let message):
120120
return "🛑 Not Supported: \(message)"
121121
}
122122
}

VirtualBuddy/CommandLine/vctool/MigrateCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ extension CatalogCommand {
5252
} else {
5353
fputs("Creating empty version 2 catalog for migration\n", stderr)
5454

55-
catalog = SoftwareCatalog(apiVersion: 2, minAppVersion: .init(string: "2.0.0")!, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [])
55+
catalog = SoftwareCatalog(apiVersion: 2, minAppVersion: .init(string: "2.0.0")!, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [], deviceSupportVersions: [])
5656
}
5757

5858
for legacyChannel in legacyCatalog.channels {

VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public struct ResolvedRestoreImage: ResolvedCatalogModel, DownloadableCatalogCon
3535
public var channel: CatalogChannel
3636
public var features: [ResolvedVirtualizationFeature]
3737
public var requirements: ResolvedRequirementSet
38+
public var deviceSupportVersion: CatalogDeviceSupportVersion?
3839
public var status: ResolvedFeatureStatus
3940
public var localFileURL: URL?
4041

@@ -46,13 +47,14 @@ public struct ResolvedRestoreImage: ResolvedCatalogModel, DownloadableCatalogCon
4647
public var downloadSize: Int64 { Int64(image.downloadSize ?? 0) }
4748
public var isDownloaded: Bool { localFileURL != nil }
4849

49-
public init(image: RestoreImage, channel: CatalogChannel, features: [ResolvedVirtualizationFeature], requirements: ResolvedRequirementSet, status: ResolvedFeatureStatus, localFileURL: URL?) {
50+
public init(image: RestoreImage, channel: CatalogChannel, features: [ResolvedVirtualizationFeature], requirements: ResolvedRequirementSet, status: ResolvedFeatureStatus, localFileURL: URL?, deviceSupportVersion: CatalogDeviceSupportVersion?) {
5051
self.image = image
5152
self.channel = channel
5253
self.features = features
5354
self.requirements = requirements
5455
self.status = status
5556
self.localFileURL = localFileURL
57+
self.deviceSupportVersion = deviceSupportVersion
5658
}
5759
}
5860

@@ -61,14 +63,21 @@ public enum ResolvedFeatureStatus: Hashable, Sendable {
6163
/// The feature is fully supported.
6264
case supported
6365
/// The feature is partially supported.
64-
case warning(message: String)
66+
case warning(title: String?, message: String)
6567
/// The feature is not supported.
66-
case unsupported(message: String)
68+
case unsupported(title: String?, message: String)
69+
70+
var title: String? {
71+
switch self {
72+
case .supported: return nil
73+
case .warning(let title, _), .unsupported(let title, _): return title
74+
}
75+
}
6776

6877
var message: String? {
6978
switch self {
7079
case .supported: return nil
71-
case .warning(let message), .unsupported(let message): return message
80+
case .warning(_, let message), .unsupported(_, let message): return message
7281
}
7382
}
7483
}
@@ -207,7 +216,8 @@ public extension ResolvedRestoreImage {
207216
features: catalog.features.map { ResolvedVirtualizationFeature(feature: $0, status: .supported, platform: environment.guestPlatform) },
208217
requirements: ResolvedRequirementSet(requirements: catalog.requirementSet(with: image.requirements), status: .supported),
209218
status: .supported,
210-
localFileURL: environment.downloadsProvider?.localFileURL(for: image)
219+
localFileURL: environment.downloadsProvider?.localFileURL(for: image),
220+
deviceSupportVersion: catalog.deviceSupportVersion(for: image)
211221
)
212222

213223
update(with: environment)
@@ -219,7 +229,11 @@ public extension ResolvedRestoreImage {
219229

220230
/// Mobile device requirement is isolated from min host/app requirements.
221231
if versionedEnvironment.mobileDeviceVersion < image.mobileDeviceMinVersion {
222-
self.status = .mobileDeviceOutdated
232+
if let deviceSupportVersion {
233+
self.status = .unsupported(title: deviceSupportVersion.title, message: deviceSupportVersion.instructions)
234+
} else {
235+
self.status = .mobileDeviceOutdated
236+
}
223237
}
224238

225239
features = features.map { $0.updated(with: versionedEnvironment) }
@@ -233,6 +247,16 @@ public extension ResolvedRestoreImage {
233247
}
234248
}
235249

250+
extension SoftwareCatalog {
251+
func deviceSupportVersion(for image: RestoreImage) -> CatalogDeviceSupportVersion? {
252+
deviceSupportVersions.first(where: {
253+
$0.mobileDeviceMinVersion == image.mobileDeviceMinVersion
254+
|| ($0.osVersion.major == image.version.major && $0.osVersion.minor == image.version.minor)
255+
|| $0.osVersion.major == image.version.major
256+
})
257+
}
258+
}
259+
236260
public extension ResolvedVirtualizationFeature {
237261
mutating func update(with environment: CatalogResolutionEnvironment) {
238262
guard !feature.unsupportedPlatform else {
@@ -286,7 +310,7 @@ public extension ResolvedRequirementSet {
286310

287311
extension ResolvedFeatureStatus {
288312
static func unsupported(_ message: String?...) -> Self {
289-
.unsupported(message: message.compactMap({ $0 }).joined(separator: "\n"))
313+
.unsupported(title: nil, message: message.compactMap({ $0 }).joined(separator: "\n"))
290314
}
291315

292316
static func unsupportedHostAndGuestAligned(_ feature: VirtualizationFeature) -> Self {
@@ -313,16 +337,19 @@ extension ResolvedFeatureStatus {
313337
.unsupported("Not supported for \(platform.name) guests.", feature.detail)
314338
}
315339

316-
317340
static var mobileDeviceOutdated: Self {
318-
.warning(message: """
319-
This version of macOS requires device support files which are not currently installed on your system.
320-
321-
It's likely that this is a beta for a major macOS version and your Mac is not running the corresponding macOS beta.
322-
323-
Device support files can be obtained by installing the Xcode beta, they are sometimes made available separately in the Apple Developer portal.
324-
""")
341+
.unsupported(title: Self.defaultDeviceSupportUpdateNeededTitle, message: Self.defaultDeviceSupportUpdateNeededInstructions)
325342
}
343+
344+
static let defaultDeviceSupportUpdateNeededTitle = "Device Support Update Required"
345+
346+
static let defaultDeviceSupportUpdateNeededInstructions = """
347+
This version of macOS requires device support files which are not currently installed on your system.
348+
349+
It's likely that this is a beta for a major macOS version and your Mac is not running the corresponding macOS beta.
350+
351+
Device support files can be obtained by installing the Xcode beta, they are sometimes made available separately in the Apple Developer portal.
352+
"""
326353
}
327354

328355
struct CatalogError: LocalizedError, CustomStringConvertible {
@@ -420,7 +447,7 @@ public extension ResolvedVirtualizationFeature {
420447
case .linux: "Supported. Requires host on macOS \(minVersionHost.shortDescription) or later."
421448
default: "Supported."
422449
}
423-
case .warning(let message), .unsupported(let message):
450+
case .warning(_, let message), .unsupported(_, let message):
424451
message
425452
}
426453
}

VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ public struct CatalogChannel: CatalogModel {
127127
}
128128
}
129129

130+
/// Describes a "device support files" installation and the instructions that should be presented to the user when it's required.
131+
public struct CatalogDeviceSupportVersion: CatalogModel {
132+
public var id: String
133+
/// Hint for which MobileDevice version this entry refers to.
134+
public var mobileDeviceMinVersion: SoftwareVersion
135+
/// OS version this entry refers to. Matching will be attempted by `major.minor` first, then `major` only.
136+
public var osVersion: SoftwareVersion
137+
/// User-facing title displayed on the list of software images.
138+
public var title: String
139+
/// User-facing instructions displayed in interstitial or when user clicks the warning. May contain markdown.
140+
public var instructions: String
141+
}
142+
130143
/// Adopted by both ``RestoreImage`` and ``ResolvedRestoreImage`` to make download lookup more convenient to implement.
131144
public protocol DownloadableCatalogContent: Identifiable, Hashable, Sendable {
132145
var build: String { get }
@@ -188,18 +201,21 @@ public struct SoftwareCatalog: Codable, Sendable {
188201
public var features: [VirtualizationFeature]
189202
/// Requirement set definitions.
190203
public var requirementSets: [RequirementSet]
204+
/// Device support files definitions.
205+
public var deviceSupportVersions: [CatalogDeviceSupportVersion]
191206

192-
public init(apiVersion: Int, minAppVersion: SoftwareVersion, channels: [CatalogChannel], groups: [CatalogGroup], restoreImages: [RestoreImage], features: [VirtualizationFeature], requirementSets: [RequirementSet]) {
207+
public init(apiVersion: Int, minAppVersion: SoftwareVersion, channels: [CatalogChannel], groups: [CatalogGroup], restoreImages: [RestoreImage], features: [VirtualizationFeature], requirementSets: [RequirementSet], deviceSupportVersions: [CatalogDeviceSupportVersion]) {
193208
self.apiVersion = apiVersion
194209
self.minAppVersion = minAppVersion
195210
self.channels = channels
196211
self.groups = groups
197212
self.restoreImages = restoreImages
198213
self.features = features
199214
self.requirementSets = requirementSets
215+
self.deviceSupportVersions = deviceSupportVersions
200216
}
201217

202-
public static let empty = SoftwareCatalog(apiVersion: 0, minAppVersion: .empty, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [])
218+
public static let empty = SoftwareCatalog(apiVersion: 0, minAppVersion: .empty, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [], deviceSupportVersions: [])
203219
}
204220

205221
public extension SoftwareCatalog {

VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/RestoreImageBrowser.swift

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,37 @@ private struct RestoreImageButton: View {
148148
.buttonStyle(RestoreImageButtonStyle(isSelected: isSelected))
149149
}
150150

151+
@State private var showingSupportDetail = false
152+
151153
@ViewBuilder
152154
var label: some View {
153-
HStack {
154-
downloadState
155+
VStack(alignment: .leading, spacing: 6) {
156+
HStack {
157+
downloadState
155158

156-
Spacer()
159+
Spacer()
157160

158-
details
161+
details
159162

160-
supportState
163+
supportState
164+
}
165+
166+
if case .unsupported(let title, _) = image.status, let title {
167+
Button {
168+
showingSupportDetail.toggle()
169+
} label: {
170+
Text(title)
171+
.multilineTextAlignment(.trailing)
172+
.frame(maxWidth: .infinity, alignment: .trailing)
173+
.font(.subheadline)
174+
}
175+
.buttonStyle(.borderless)
176+
.foregroundStyle(Color.red)
177+
.blendMode(.plusLighter)
178+
.popover(isPresented: $showingSupportDetail) {
179+
RestoreImageFeatureDetailView(image: image)
180+
}
181+
}
161182
}
162183
.monospacedDigit()
163184
.contextMenu {
@@ -225,8 +246,8 @@ struct RestoreImageFeatureStatusButton: View {
225246
var helpText: String {
226247
switch status {
227248
case .supported: "This version is supported on your Mac. Click for details about supported features."
228-
case .warning(let message): message
229-
case .unsupported(let message): message
249+
case .warning(let title, let message): title ?? message
250+
case .unsupported(let title, let message): title ?? message
230251
}
231252
}
232253

@@ -280,18 +301,28 @@ struct RestoreImageFeatureDetailView: View {
280301
switch image.status {
281302
case .supported:
282303
EmptyView()
283-
case .warning(let message), .unsupported(let message):
304+
case .warning(let title, let message), .unsupported(let title, let message):
284305
VStack(alignment: .leading, spacing: 12) {
285306
HStack {
286307
FeatureStatusLabel(status: image.status)
287308

288-
Text("This Version May Not Work")
309+
if let title {
310+
Text(title)
311+
} else {
312+
Text("This Version May Not Work")
313+
}
289314
}
290315
.imageScale(.large)
291316
.font(.headline)
292317

293-
Text(message)
294-
.fixedSize(horizontal: false, vertical: true)
318+
Group {
319+
if let attributedMessage = try? AttributedString(markdown: message, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .inlineOnlyPreservingWhitespace, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: nil)) {
320+
Text(attributedMessage)
321+
} else {
322+
Text(message)
323+
}
324+
}
325+
.fixedSize(horizontal: false, vertical: true)
295326
}
296327
.frame(maxWidth: .infinity, alignment: .leading)
297328
.padding()

VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/SoftwareCatalog+Placeholder.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ extension ResolvedRequirementSet {
6868
}
6969

7070
extension SoftwareCatalog {
71-
static let placeholder = SoftwareCatalog(apiVersion: 1, minAppVersion: "1.0", channels: [.placeholder], groups: [.placeholder], restoreImages: [.placeholder], features: [], requirementSets: [.placeholder])
71+
static let placeholder = SoftwareCatalog(apiVersion: 1, minAppVersion: "1.0", channels: [.placeholder], groups: [.placeholder], restoreImages: [.placeholder], features: [], requirementSets: [.placeholder], deviceSupportVersions: [])
7272
}
7373

7474
extension ResolvedRestoreImage {
75-
static let placeholder = ResolvedRestoreImage(image: .placeholder, channel: .placeholder, features: [], requirements: .placeholder, status: .supported, localFileURL: nil)
75+
static let placeholder = ResolvedRestoreImage(image: .placeholder, channel: .placeholder, features: [], requirements: .placeholder, status: .supported, localFileURL: nil, deviceSupportVersion: nil)
7676
}

data/ipsws_v2.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,15 @@
208208
"minVersionHost" : "13.0.0"
209209
}
210210
],
211+
"deviceSupportVersions": [
212+
{
213+
"id": "device_support_macOS26_beta",
214+
"mobileDeviceMinVersion": "1810.0.0",
215+
"osVersion": "26.0.0",
216+
"title": "Device Support Update Required",
217+
"instructions": "Your Mac needs an update to install virtual machines that use this version of macOS.\n\nHere’s how you can install the update:\n\n1. Head over to the [Apple Developer downloads page](https://developer.apple.com/download/).\n2. Under “Operating Systems”, find “Device Support for macOS 26”.\n3. Click the link below that item.\n4. Double-click the downloaded DMG and open the installer. Follow the instructions.\n5. Quit and relaunch VirtualBuddy. Then, try again.\n\nInstalling the latest Xcode beta will also install the required update."
218+
}
219+
],
211220
"restoreImages" : [
212221
{
213222
"build" : "25A5295e",

0 commit comments

Comments
 (0)