diff --git a/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift b/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift index a093a85d1..3a4c086cc 100644 --- a/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift +++ b/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift @@ -13,8 +13,7 @@ enum DeviceModelCatalog { let model = (modelIdentifier ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let friendlyName = model.isEmpty ? nil : self.modelIdentifierToName[model] - let symbol = self.symbolFor(modelIdentifier: model, friendlyName: friendlyName) - ?? self.fallbackSymbol(for: family, modelIdentifier: model) + let symbol = self.symbol(deviceFamily: family, modelIdentifier: model, friendlyName: friendlyName) let title = if let friendlyName, !friendlyName.isEmpty { friendlyName @@ -32,6 +31,14 @@ enum DeviceModelCatalog { 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 } @@ -47,17 +54,15 @@ enum DeviceModelCatalog { if lower.hasPrefix("macbook") || lower.hasPrefix("macbookpro") || lower.hasPrefix("macbookair") { return "laptopcomputer" } - if lower.hasPrefix("imac") || lower.hasPrefix("macmini") || lower.hasPrefix("macpro") || lower - .hasPrefix("macstudio") - { - return "desktopcomputer" - } + 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 "desktopcomputer" } - if friendlyNameLower.contains("mac studio") { return "desktopcomputer" } + if friendlyNameLower.contains("mac mini") { return "macmini" } + if friendlyNameLower.contains("mac studio") { return "macstudio" } if friendlyNameLower.contains("mac pro") { return "desktopcomputer" } } @@ -75,8 +80,7 @@ enum DeviceModelCatalog { case "mac": return "laptopcomputer" case "android": - // Prefer tablet glyph when we know it's an Android tablet. (No attempt to infer phone/tablet here.) - return "cpu" + return "logo.android" case "linux": return "cpu" default: diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index dd301a7de..34d1508c8 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -60,47 +60,67 @@ struct InstancesSettings: View { @ViewBuilder private func instanceRow(_ inst: InstanceInfo) -> some View { let isGateway = (inst.mode ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway" + let prettyPlatform = inst.platform.flatMap { self.prettyPlatform($0) } + let device = DeviceModelCatalog.presentation( + deviceFamily: inst.deviceFamily, + modelIdentifier: inst.modelIdentifier) + let leadingSymbol = self.leadingDeviceSymbol(inst, device: device) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(inst.host ?? "unknown host").font(.subheadline.bold()) - if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } - } - HStack(spacing: 8) { - if let version = inst.version { - self.label(icon: "shippingbox", text: version) + HStack(alignment: .top, spacing: 12) { + Image(systemName: leadingSymbol) + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28, alignment: .center) + .padding(.top, 1) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(inst.host ?? "unknown host").font(.subheadline.bold()) + if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } } - let prettyPlatform = inst.platform.flatMap { self.prettyPlatform($0) } - let device = DeviceModelCatalog.presentation( - deviceFamily: inst.deviceFamily, - modelIdentifier: inst.modelIdentifier) - if let device { - // Avoid showing generic "Mac"/"iPhone"/etc; prefer the concrete model name. - let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let isGeneric = !family.isEmpty && device.title == family - if !isGeneric { - if let prettyPlatform { - self.label(icon: device.symbol, text: "\(device.title) · \(prettyPlatform)") - } else { - self.label(icon: device.symbol, text: device.title) + HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(spacing: 8) { + if let version = inst.version { + self.label(icon: "shippingbox", text: version) } - } else if let prettyPlatform, let platform = inst.platform { - self.label(icon: self.platformIcon(platform), text: prettyPlatform) + + if let device { + // Avoid showing generic "Mac"/"iPhone"/etc; prefer the concrete model name. + let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let isGeneric = !family.isEmpty && device.title == family + if !isGeneric { + if let prettyPlatform { + self.label(icon: device.symbol, text: "\(device.title) · \(prettyPlatform)") + } else { + self.label(icon: device.symbol, text: device.title) + } + } else if let prettyPlatform, let platform = inst.platform { + self.label(icon: self.platformIcon(platform), text: prettyPlatform) + } + } else if let prettyPlatform, let platform = inst.platform { + self.label(icon: self.platformIcon(platform), text: prettyPlatform) + } + + if let mode = inst.mode { self.label(icon: "network", text: mode) } } - } else if let prettyPlatform, let platform = inst.platform { - self.label(icon: self.platformIcon(platform), text: prettyPlatform) - } + .layoutPriority(1) - // Last local input is helpful for interactive nodes, but noisy/meaningless for the gateway. - if !isGateway, let secs = inst.lastInputSeconds { - self.label(icon: "clock", text: "\(secs)s ago") - } - if let mode = inst.mode { self.label(icon: "network", text: mode) } + Spacer(minLength: 0) - if let update = self.updateSummaryText(inst, isGateway: isGateway) { - self.label(icon: "arrow.clockwise", text: update) - .help(self.presenceUpdateSourceHelp(inst.reason ?? "")) + HStack(spacing: 8) { + // Last local input is helpful for interactive nodes, but noisy/meaningless for the gateway. + if !isGateway, let secs = inst.lastInputSeconds { + self.label(icon: "clock", text: "\(secs)s ago") + } + + if let update = self.updateSummaryText(inst, isGateway: isGateway) { + self.label(icon: "arrow.clockwise", text: update) + .help(self.presenceUpdateSourceHelp(inst.reason ?? "")) + } + } + .foregroundStyle(.secondary) } } } @@ -116,7 +136,7 @@ struct InstancesSettings: View { private func label(icon: String?, text: String) -> some View { HStack(spacing: 4) { - if let icon { + if let icon, self.isSystemSymbolAvailable(icon) { Image(systemName: icon).foregroundStyle(.secondary).font(.caption) } Text(text) @@ -124,6 +144,47 @@ struct InstancesSettings: View { .font(.footnote) } + private func leadingDeviceSymbol(_ inst: InstanceInfo, device: DevicePresentation?) -> String { + let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if family == "android" { + return self.safeSystemSymbol("logo.android", fallback: "cpu") + } + + if let title = device?.title.lowercased() { + if title.contains("mac studio") { + return self.safeSystemSymbol("macstudio", fallback: "desktopcomputer") + } + if title.contains("macbook") { + return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") + } + if title.contains("ipad") { + return self.safeSystemSymbol("ipad", fallback: "ipad") + } + if title.contains("iphone") { + return self.safeSystemSymbol("iphone", fallback: "iphone") + } + } + + if let symbol = device?.symbol { + return self.safeSystemSymbol(symbol, fallback: "cpu") + } + + if let platform = inst.platform { + return self.safeSystemSymbol(self.platformIcon(platform), fallback: "cpu") + } + + return "cpu" + } + + private func safeSystemSymbol(_ preferred: String, fallback: String) -> String { + if self.isSystemSymbolAvailable(preferred) { return preferred } + return fallback + } + + private func isSystemSymbolAvailable(_ name: String) -> Bool { + NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + } + private func platformIcon(_ raw: String) -> String { let (prefix, _) = self.parsePlatform(raw) switch prefix { diff --git a/apps/macos/Tests/ClawdisIPCTests/DeviceModelCatalogTests.swift b/apps/macos/Tests/ClawdisIPCTests/DeviceModelCatalogTests.swift new file mode 100644 index 000000000..ff4b275d3 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/DeviceModelCatalogTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import Clawdis + +@Suite +struct DeviceModelCatalogTests { + @Test + func symbolPrefersModelIdentifierPrefixes() { + #expect(DeviceModelCatalog.symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad") + #expect(DeviceModelCatalog.symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone") + } + + @Test + func symbolUsesFriendlyNameForMacVariants() { + #expect(DeviceModelCatalog.symbol( + deviceFamily: "Mac", + modelIdentifier: "Mac99,1", + friendlyName: "Mac Studio (2025)") == "macstudio") + #expect(DeviceModelCatalog.symbol( + deviceFamily: "Mac", + modelIdentifier: "Mac99,2", + friendlyName: "Mac mini (2024)") == "macmini") + #expect(DeviceModelCatalog.symbol( + deviceFamily: "Mac", + modelIdentifier: "Mac99,3", + friendlyName: "MacBook Pro (14-inch, 2024)") == "laptopcomputer") + } + + @Test + func symbolFallsBackToDeviceFamily() { + #expect(DeviceModelCatalog.symbol(deviceFamily: "Android", modelIdentifier: "", friendlyName: nil) == "logo.android") + #expect(DeviceModelCatalog.symbol(deviceFamily: "Linux", modelIdentifier: "", friendlyName: nil) == "cpu") + } +} +