openclaw/apps/macos/Sources/Clawdbot/DeviceModelCatalog.swift
Haku 🐉 a7e4a26d3d fix(macos): Avoid Bundle.module crash in DeviceModelCatalog
Bundle.module is a SwiftPM-generated computed property that calls
fatalError() when the resource bundle isn't found. This happens in
packaged .app bundles where SwiftPM's expected bundle paths differ
from the actual layout.

The fix uses manual bundle lookup (similar to ClawdbotKitResources.swift
and Textual's Bundle.textual) to search known locations without crashing.

This addresses the same class of issue fixed in Textual 0.3.1:
https://github.com/gonzalezreal/textual/pull/21
2026-01-26 23:43:50 +00:00

226 lines
8.3 KiB
Swift

import Foundation
struct DevicePresentation: Sendable {
let title: String
let symbol: String?
}
enum DeviceModelCatalog {
private static let modelIdentifierToName: [String: String] = loadModelIdentifierToName()
private static let resourceBundle: Bundle? = locateResourceBundle()
private static let resourceSubdirectory = "DeviceModels"
static func presentation(deviceFamily: String?, modelIdentifier: String?) -> DevicePresentation? {
let family = (deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let model = (modelIdentifier ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let friendlyName = model.isEmpty ? nil : self.modelIdentifierToName[model]
let symbol = self.symbol(deviceFamily: family, modelIdentifier: model, friendlyName: friendlyName)
let title = if let friendlyName, !friendlyName.isEmpty {
friendlyName
} else if !family.isEmpty, !model.isEmpty {
"\(family) (\(model))"
} else if !family.isEmpty {
family
} else if !model.isEmpty {
model
} else {
""
}
if title.isEmpty { return nil }
return DevicePresentation(title: title, symbol: symbol)
}
static func symbol(
deviceFamily familyRaw: String,
modelIdentifier modelIdentifierRaw: String,
friendlyName: String?) -> String?
{
let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines)
let modelIdentifier = modelIdentifierRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return self.symbolFor(modelIdentifier: modelIdentifier, friendlyName: friendlyName)
?? self.fallbackSymbol(for: family, modelIdentifier: modelIdentifier)
}
private static func symbolFor(modelIdentifier rawModelIdentifier: String, friendlyName: String?) -> String? {
let modelIdentifier = rawModelIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !modelIdentifier.isEmpty else { return nil }
let lower = modelIdentifier.lowercased()
if lower.hasPrefix("ipad") { return "ipad" }
if lower.hasPrefix("iphone") { return "iphone" }
if lower.hasPrefix("ipod") { return "iphone" }
if lower.hasPrefix("watch") { return "applewatch" }
if lower.hasPrefix("appletv") { return "appletv" }
if lower.hasPrefix("audio") || lower.hasPrefix("homepod") { return "speaker" }
if lower.hasPrefix("macbook") || lower.hasPrefix("macbookpro") || lower.hasPrefix("macbookair") {
return "laptopcomputer"
}
if lower.hasPrefix("macstudio") { return "macstudio" }
if lower.hasPrefix("macmini") { return "macmini" }
if lower.hasPrefix("imac") || lower.hasPrefix("macpro") { return "desktopcomputer" }
if lower.hasPrefix("mac"), let friendlyNameLower = friendlyName?.lowercased() {
if friendlyNameLower.contains("macbook") { return "laptopcomputer" }
if friendlyNameLower.contains("imac") { return "desktopcomputer" }
if friendlyNameLower.contains("mac mini") { return "macmini" }
if friendlyNameLower.contains("mac studio") { return "macstudio" }
if friendlyNameLower.contains("mac pro") { return "desktopcomputer" }
}
return nil
}
private static func fallbackSymbol(for familyRaw: String, modelIdentifier: String) -> String? {
let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if family.isEmpty { return nil }
switch family.lowercased() {
case "ipad":
return "ipad"
case "iphone":
return "iphone"
case "mac":
return "laptopcomputer"
case "android":
return "android"
case "linux":
return "cpu"
default:
return "cpu"
}
}
private static func loadModelIdentifierToName() -> [String: String] {
var combined: [String: String] = [:]
combined.merge(
self.loadMapping(resourceName: "ios-device-identifiers"),
uniquingKeysWith: { current, _ in current })
combined.merge(
self.loadMapping(resourceName: "mac-device-identifiers"),
uniquingKeysWith: { current, _ in current })
return combined
}
private static func loadMapping(resourceName: String) -> [String: String] {
guard let url = self.resourceBundle?.url(
forResource: resourceName,
withExtension: "json",
subdirectory: self.resourceSubdirectory)
else { return [:] }
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([String: NameValue].self, from: data)
return decoded.compactMapValues { $0.normalizedName }
} catch {
return [:]
}
}
private static func locateResourceBundle() -> Bundle? {
// Prefer main bundle (packaged app), then module bundle (SwiftPM/tests).
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
return bundle
}
// Try to locate the module bundle manually to avoid fatalError from Bundle.module
// when the bundle isn't where SwiftPM expects it (e.g., in packaged .app bundles).
if let bundle = locateModuleBundleSafely() {
if let checked = self.bundleIfContainsDeviceModels(bundle) {
return checked
}
}
return nil
}
private static func locateModuleBundleSafely() -> Bundle? {
let bundleName = "Clawdbot_Clawdbot"
let candidates: [URL?] = [
Bundle.main.resourceURL,
Bundle.main.bundleURL,
Bundle(for: BundleLocator.self).resourceURL,
Bundle(for: BundleLocator.self).bundleURL,
]
for candidate in candidates {
guard let baseURL = candidate else { continue }
// Direct path
let directURL = baseURL.appendingPathComponent("\(bundleName).bundle")
if let bundle = Bundle(url: directURL) {
return bundle
}
// Inside Resources/
let resourcesURL = baseURL
.appendingPathComponent("Resources")
.appendingPathComponent("\(bundleName).bundle")
if let bundle = Bundle(url: resourcesURL) {
return bundle
}
}
return nil
}
private static func bundleIfContainsDeviceModels(_ bundle: Bundle) -> Bundle? {
if bundle.url(
forResource: "ios-device-identifiers",
withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil
{
return bundle
}
if bundle.url(
forResource: "mac-device-identifiers",
withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil
{
return bundle
}
return nil
}
private enum NameValue: Decodable {
case string(String)
case stringArray([String])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let s = try? container.decode(String.self) {
self = .string(s)
return
}
if let arr = try? container.decode([String].self) {
self = .stringArray(arr)
return
}
throw DecodingError.typeMismatch(
String.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected string or string array"))
}
var normalizedName: String? {
switch self {
case let .string(s):
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
case let .stringArray(arr):
let values = arr
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !values.isEmpty else { return nil }
return values.joined(separator: " / ")
}
}
}
}
// Helper class for bundle lookup via Bundle(for:)
private final class BundleLocator {}