feat(hosts): expand icons and fix automatic previews

This commit is contained in:
Kaede Akino committed 2026-10-17 00:54:34 +08:00
1 parent 21accf8ae5
commit b139fbc841
24 files changed
+1241 -72

No files matched your search

+44 -3
View File
@@ -10,6 +10,43 @@ import struct TermindDomain.Host
@Suite @MainActor
struct HostIconTests {
@Test(arguments: HostGeneralIcon.allCases)
func generalIconsOverrideDetectionWithoutBecomingDistributionMatches(icon: HostGeneralIcon) throws {
let catalog = try fixtureCatalog()
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
#expect(
catalog.resolvedArtwork(overrideID: icon.id, distribution: ubuntu) == .systemImage(icon.systemImageName))
#expect(catalog.resolvedArtwork(overrideID: icon.id, distribution: nil) == .systemImage(icon.systemImageName))
#expect(catalog.resolvedEntry(overrideID: icon.id, distribution: ubuntu) == nil)
#expect(catalog.resolvedArtwork(overrideID: nil, distribution: ubuntu) == .asset("LinuxDistro-ubuntu"))
#expect(catalog.resolvedArtwork(overrideID: nil, distribution: nil) == .systemImage("server.rack"))
}
@Test func artworkPreservesMatchingAndUnknownOverrideFallbacks() throws {
let catalog = try fixtureCatalog()
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
#expect(catalog.resolvedArtwork(overrideID: "future-icon", distribution: ubuntu) == .systemImage("server.rack"))
#expect(catalog.resolvedArtwork(overrideID: "windows", distribution: ubuntu) == .asset("HostOS-windows"))
#expect(
catalog.resolvedArtwork(
overrideID: nil, distribution: DetectedDistribution(id: "custom", likeIDs: ["debian"]))
== .asset("LinuxDistro-debian"))
#expect(
catalog.resolvedArtwork(overrideID: nil, distribution: DetectedDistribution(id: "new-linux"))
== .asset("HostOS-linux"))
}
@Test func generalIconsKeepReferenceOrderAndDoNotDuplicateOperatingSystems() throws {
#expect(
HostGeneralIcon.allCases.map(\.id) == [
"server", "cloud", "terminal", "code", "database", "storage", "container", "network",
"shield", "home_lab", "web", "api", "compute", "cluster", "router", "automation",
"monitoring", "backup", "media", "git", "ai", "virtual_machine",
])
let catalog = try fixtureCatalog()
#expect(Set(HostGeneralIcon.allCases.map(\.id)).isDisjoint(with: catalog.entries.map(\.id)))
}
@Test func darwinResolvesWithoutLinuxMetadata() throws {
for kernel in ["Darwin", "darwin", "DARWIN"] {
let info = RemoteSystemInfo(kernelName: kernel, kernelRelease: "25.0", architecture: "arm64")
@@ -118,7 +155,7 @@ struct HostIconTests {
let host = Host(name: "Host", host: "example.invalid")
let data = try JSONEncoder().encode(host)
#expect(try JSONDecoder().decode(Host.self, from: data).iconOverrideID == nil)
for id in ["server", "windows", "macos", "future-icon"] {
for id in HostGeneralIcon.allCases.map(\.id) + ["windows", "macos", "future-icon"] {
var manual = host
manual.iconOverrideID = id
#expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(manual)) == manual)
@@ -137,7 +174,11 @@ struct HostIconTests {
#expect(library.saveHost(host))
host = try #require(library.host(host.id))
let journal = VaultSyncJournal(context: source.mainContext)
for choice: String? in ["windows", "macos", "freebsd", "bsd", "linux", "future-icon", nil] {
let choices: [String?] =
HostGeneralIcon.allCases.map { $0.id } + [
"windows", "macos", "freebsd", "bsd", "linux", "future-icon", nil,
]
for choice in choices {
host.iconOverrideID = choice
#expect(library.saveHost(host))
let envelope = try #require(try journal.envelope(for: .init(kind: .host, id: host.id)))
@@ -157,7 +198,7 @@ struct HostIconTests {
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer], groupID: group.id)
var host = try #require(library.hosts.first)
for choice: String? in ["windows", nil] {
for choice: String? in ["windows", "cloud", "virtual_machine", nil] {
host.iconOverrideID = choice
#expect(library.saveTailscaleHost(host))
library.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Darwin"), for: host.id)
+738
View File
@@ -5138,6 +5138,47 @@
}
}
},
"API" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "API"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "API"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "API"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "API"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "API"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "API"
}
}
}
},
"API Key" : {
"extractionState" : "manual",
"localizations" : {
@@ -11003,6 +11044,47 @@
}
}
},
"Automation" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatisierung"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatisation"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "自動化"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "자동화"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "自动化"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "自動化"
}
}
}
},
"Available Models" : {
"extractionState" : "manual",
"localizations" : {
@@ -11255,6 +11337,47 @@
}
}
},
"Backup" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sicherung"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sauvegarde"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "バックアップ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "백업"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "备份"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "備份"
}
}
}
},
"Basics" : {
"extractionState" : "manual",
"localizations" : {
@@ -15417,6 +15540,129 @@
}
}
},
"Cloud" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cloud"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cloud"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "クラウド"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "클라우드"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "云端"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "雲端"
}
}
}
},
"Cluster" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cluster"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cluster"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "クラスター"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "클러스터"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "集群"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "叢集"
}
}
}
},
"Code" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Code"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Code"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "コード"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "코드"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "代码"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "程式碼"
}
}
}
},
"Collapse" : {
"extractionState" : "manual",
"localizations" : {
@@ -16619,6 +16865,47 @@
}
}
},
"Compute" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rechenleistung"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Calcul"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "コンピューティング"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "컴퓨팅"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "计算"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "運算"
}
}
}
},
"Concurrent Strategy" : {
"extractionState" : "manual",
"localizations" : {
@@ -18189,6 +18476,47 @@
}
}
},
"Container" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Container"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conteneur"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "コンテナ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "컨테이너"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "容器"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "容器"
}
}
}
},
"Contains shell syntax that cannot be proven read-only." : {
"extractionState" : "manual",
"localizations" : {
@@ -20802,6 +21130,47 @@
}
}
},
"Database" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Datenbank"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Base de données"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "データベース"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "데이터베이스"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "数据库"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "資料庫"
}
}
}
},
"Database in Use" : {
"extractionState" : "manual",
"localizations" : {
@@ -31960,6 +32329,47 @@
}
}
},
"Git" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git"
}
}
}
},
"Google Vertex AI" : {
"extractionState" : "manual",
"localizations" : {
@@ -33116,6 +33526,47 @@
}
}
},
"Home Lab" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Heimlabor"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Laboratoire personnel"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ホームラボ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "홈 랩"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "家庭实验室"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "家庭實驗室"
}
}
}
},
"Host" : {
"extractionState" : "manual",
"localizations" : {
@@ -39593,6 +40044,47 @@
}
}
},
"Media" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Medien"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Médias"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "メディア"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "미디어"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "媒体"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "媒體"
}
}
}
},
"Memory" : {
"extractionState" : "manual",
"localizations" : {
@@ -40543,6 +41035,47 @@
}
}
},
"Monitoring" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Überwachung"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Surveillance"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "監視"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "모니터링"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "监控"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "監控"
}
}
}
},
"More" : {
"extractionState" : "manual",
"localizations" : {
@@ -57986,6 +58519,47 @@
}
}
},
"Router" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Router"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Routeur"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ルーター"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "라우터"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "路由器"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "路由器"
}
}
}
},
"Routing" : {
"extractionState" : "manual",
"localizations" : {
@@ -63319,6 +63893,47 @@
}
}
},
"Shield" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schild"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bouclier"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "シールド"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "방패"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "盾牌"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "盾牌"
}
}
}
},
"Show" : {
"extractionState" : "manual",
"localizations" : {
@@ -66848,6 +67463,47 @@
}
}
},
"Storage" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Speicher"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stockage"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ストレージ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "스토리지"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "存储"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "儲存"
}
}
}
},
"Store Password in" : {
"extractionState" : "manual",
"localizations" : {
@@ -82917,6 +83573,47 @@
}
}
},
"Virtual Machine" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Virtuelle Maschine"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Machine virtuelle"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "仮想マシン"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "가상 머신"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "虚拟机"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "虛擬機"
}
}
}
},
"Voice Input" : {
"extractionState" : "manual",
"localizations" : {
@@ -83962,6 +84659,47 @@
}
}
},
"Web" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Web"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Web"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ウェブ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "웹"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网站"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "網站"
}
}
}
},
"Web Search" : {
"extractionState" : "manual",
"localizations" : {
@@ -6,15 +6,18 @@ import struct TermindDomain.Host
/// The same host metadata form inside a desktop work column or a mobile sheet.
public struct HostOrganizationSection<GroupField: View, TagsField: View>: View {
@Binding private var host: Host
private let detectedDistribution: DetectedDistribution?
private let groupField: GroupField
private let tagsField: TagsField
public init(
host: Binding<Host>,
detectedDistribution: DetectedDistribution?,
@ViewBuilder groupField: () -> GroupField,
@ViewBuilder tagsField: () -> TagsField
) {
_host = host
self.detectedDistribution = detectedDistribution
self.groupField = groupField()
self.tagsField = tagsField()
}
@@ -24,7 +27,7 @@ public struct HostOrganizationSection<GroupField: View, TagsField: View>: View {
VaultNameField(text: $host.name, prompt: Text(verbatim: host.defaultName))
.disabled(host.isTailscaleManaged)
groupField.disabled(host.isTailscaleManaged)
HostIconPicker(selection: $host.iconOverrideID)
HostIconPicker(selection: $host.iconOverrideID, detectedDistribution: detectedDistribution)
tagsField
VaultColorField(selection: $host.colorTag)
}
@@ -21,7 +21,7 @@ public struct HostDistributionIcon: View {
host?.colorTag.color
?? entry.flatMap { HostIconBrandColor.color(hex: $0.hex, colorScheme: colorScheme) }
?? .accentColor
HostSystemIcon(asset: entry?.asset, size: size, style: artworkStyle)
HostSystemIcon(artwork: HostIconCatalog.resolvedArtwork(for: host), size: size, style: artworkStyle)
.tint(tint)
}
}
@@ -0,0 +1,82 @@
import Foundation
public nonisolated enum HostGeneralIcon: String, CaseIterable, Identifiable, Sendable {
case server
case cloud
case terminal
case code
case database
case storage
case container
case network
case shield
case homeLab = "home_lab"
case web
case api
case compute
case cluster
case router
case automation
case monitoring
case backup
case media
case git
case artificialIntelligence = "ai"
case virtualMachine = "virtual_machine"
public var id: String { rawValue }
public var title: LocalizedStringResource {
switch self {
case .server: AppStrings.HostIcons.server
case .cloud: AppStrings.HostIcons.cloud
case .terminal: AppStrings.Settings.terminal
case .code: AppStrings.HostIcons.code
case .database: AppStrings.HostIcons.database
case .storage: AppStrings.HostIcons.storage
case .container: AppStrings.HostIcons.container
case .network: AppStrings.HostIcons.network
case .shield: AppStrings.HostIcons.shield
case .homeLab: AppStrings.HostIcons.homeLab
case .web: AppStrings.HostIcons.web
case .api: AppStrings.HostIcons.api
case .compute: AppStrings.HostIcons.compute
case .cluster: AppStrings.HostIcons.cluster
case .router: AppStrings.HostIcons.router
case .automation: AppStrings.HostIcons.automation
case .monitoring: AppStrings.HostIcons.monitoring
case .backup: AppStrings.HostIcons.backup
case .media: AppStrings.HostIcons.media
case .git: AppStrings.HostIcons.git
case .artificialIntelligence: AppStrings.Settings.ai
case .virtualMachine: AppStrings.HostIcons.virtualMachine
}
}
public var systemImageName: String {
switch self {
case .server: "server.rack"
case .cloud: "cloud"
case .terminal: "terminal"
case .code: "chevron.left.forwardslash.chevron.right"
case .database: "cylinder"
case .storage: "internaldrive"
case .container: "shippingbox"
case .network: "network"
case .shield: "shield"
case .homeLab: "house"
case .web: "globe"
case .api: "curlybraces"
case .compute: "cpu"
case .cluster: "circle.grid.3x3"
case .router: "wifi.router"
case .automation: "gearshape.2"
case .monitoring: "chart.xyaxis.line"
case .backup: "externaldrive.badge.timemachine"
case .media: "play.rectangle"
case .git: "arrow.triangle.branch"
case .artificialIntelligence: "brain"
case .virtualMachine: "square.stack.3d.up"
}
}
}
@@ -0,0 +1,21 @@
import SwiftUI
public nonisolated enum HostIconArtwork: Equatable, Sendable {
case asset(String)
case systemImage(String)
public var assetName: String? {
if case .asset(let name) = self { name } else { nil }
}
public var systemImageName: String? {
if case .systemImage(let name) = self { name } else { nil }
}
public var image: Image {
switch self {
case .asset(let name): Image(name, bundle: .main)
case .systemImage(let name): Image(systemName: name)
}
}
}
@@ -36,6 +36,20 @@ public nonisolated struct HostIconCatalog: Codable, Sendable {
return distribution.flatMap { entry(matching: $0) }
}
public static func resolvedArtwork(for host: TermindDomain.Host?) -> HostIconArtwork {
bundled.resolvedArtwork(overrideID: host?.iconOverrideID, distribution: host?.detectedDistribution)
}
public func resolvedArtwork(overrideID: String?, distribution: DetectedDistribution?) -> HostIconArtwork {
if let overrideID, let icon = HostGeneralIcon(rawValue: overrideID) {
return .systemImage(icon.systemImageName)
}
if let entry = resolvedEntry(overrideID: overrideID, distribution: distribution) {
return .asset(entry.asset)
}
return .systemImage(HostGeneralIcon.server.systemImageName)
}
public var selectableEntries: [HostIconEntry] {
entries.sorted {
let comparison = $0.title.compare(
@@ -1,33 +1,95 @@
import SwiftUI
import TermindDomain
public struct HostIconPicker: View {
@Environment(\.displayScale) private var displayScale
@Binding private var selection: String?
private let detectedDistribution: DetectedDistribution?
public init(selection: Binding<String?>) {
public init(selection: Binding<String?>, detectedDistribution: DetectedDistribution?) {
_selection = selection
self.detectedDistribution = detectedDistribution
}
public var body: some View {
Picker(AppStrings.Vault.icon.swiftUIKey, selection: $selection) {
Label(AppStrings.Common.automatic.swiftUIKey, systemImage: "sparkles").tag(String?.none)
Label(AppStrings.Vault.serverIcon.swiftUIKey, systemImage: "server.rack").tag(String?.some("server"))
Divider()
ForEach(HostIconCatalog.bundled.selectableEntries) { entry in
Label {
Text(verbatim: entry.title)
} icon: {
Image(entry.asset, bundle: .main).resizable().scaledToFit().frame(width: 16, height: 16)
LabeledContent(AppStrings.Vault.icon.swiftUIKey) {
Menu {
// Picker flattens internal sections; separate pickers preserve native menu dividers.
Picker(AppStrings.Vault.icon.swiftUIKey, selection: $selection) {
iconLabel(Text(AppStrings.Common.automatic), artwork: artwork(for: nil))
.tag(String?.none)
}
.tag(String?.some(entry.id))
}
if let selection, selection != "server",
!HostIconCatalog.bundled.entries.contains(where: { $0.id == selection })
{
Label(AppStrings.Vault.unknownIcon.swiftUIKey, systemImage: "questionmark.square")
.tag(String?.some(selection))
.labelsHidden()
Divider()
Picker(AppStrings.Vault.icon.swiftUIKey, selection: $selection) {
ForEach(HostGeneralIcon.allCases) { icon in
iconLabel(Text(icon.title), artwork: .systemImage(icon.systemImageName))
.tag(String?.some(icon.id))
}
}
.labelsHidden()
Divider()
Picker(AppStrings.Vault.icon.swiftUIKey, selection: $selection) {
ForEach(HostIconCatalog.bundled.selectableEntries) { entry in
iconLabel(Text(verbatim: entry.title), artwork: .asset(entry.asset))
.tag(String?.some(entry.id))
}
if let selection, HostGeneralIcon(rawValue: selection) == nil,
!HostIconCatalog.bundled.entries.contains(where: { $0.id == selection })
{
iconLabel(Text(AppStrings.Vault.unknownIcon), artwork: artwork(for: selection))
.tag(String?.some(selection))
}
}
.labelsHidden()
} label: {
iconLabel(selectionTitle, artwork: artwork(for: selection))
}
.pickerStyle(.inline)
.menuOrder(.fixed)
.accessibilityLabel(Text(AppStrings.Vault.icon))
.accessibilityValue(selectionTitle)
.accessibilityIdentifier("hostIcon.picker")
#if os(macOS)
.menuStyle(.borderlessButton)
#endif
}
.pickerStyle(.menu)
.menuOrder(.fixed)
}
private var selectionTitle: Text {
guard let selection else { return Text(AppStrings.Common.automatic) }
if let icon = HostGeneralIcon(rawValue: selection) { return Text(icon.title) }
if let entry = HostIconCatalog.bundled.entries.first(where: { $0.id == selection }) {
return Text(verbatim: entry.title)
}
return Text(AppStrings.Vault.unknownIcon)
}
private func artwork(for overrideID: String?) -> HostIconArtwork {
HostIconCatalog.bundled.resolvedArtwork(overrideID: overrideID, distribution: detectedDistribution)
}
private func iconLabel(_ title: Text, artwork: HostIconArtwork) -> some View {
Label {
title.lineLimit(1)
} icon: {
menuImage(for: artwork).resizable().scaledToFit().frame(width: 16, height: 16)
}
.labelStyle(.titleAndIcon)
}
private func menuImage(for artwork: HostIconArtwork) -> Image {
#if os(macOS)
// Native menus extract the underlying image and ignore SwiftUI's frame.
if artwork.assetName != nil {
let renderer = ImageRenderer(
content: artwork.image.resizable().scaledToFit().frame(width: 16, height: 16))
renderer.scale = displayScale
if let image = renderer.cgImage {
return Image(decorative: image, scale: displayScale).renderingMode(.template)
}
}
#endif
return artwork.image
}
}
@@ -7,13 +7,25 @@ public struct HostSystemIcon: View {
case desktopCard
}
private let asset: String?
private let artwork: HostIconArtwork
private let size: CGFloat
private let showsBackground: Bool
private let style: Style
public init(asset: String?, size: CGFloat = 32, showsBackground: Bool = true, style: Style = .standard) {
self.asset = asset
public init(
asset: String?, systemImageName: String? = nil, size: CGFloat = 32,
showsBackground: Bool = true, style: Style = .standard
) {
self.init(
artwork: asset.map(HostIconArtwork.asset)
?? .systemImage(systemImageName ?? HostGeneralIcon.server.systemImageName),
size: size, showsBackground: showsBackground, style: style)
}
public init(
artwork: HostIconArtwork, size: CGFloat = 32, showsBackground: Bool = true, style: Style = .standard
) {
self.artwork = artwork
self.size = size
self.showsBackground = showsBackground
self.style = style
@@ -21,12 +33,10 @@ public struct HostSystemIcon: View {
public var body: some View {
Group {
if let asset {
Image(asset, bundle: .main).resizable().scaledToFit()
} else if style == .desktopCard {
Image(systemName: "server.rack").font(.system(size: 14, weight: .medium))
if artwork.systemImageName != nil, style == .desktopCard {
artwork.image.font(.system(size: 14, weight: .medium))
} else {
Image(systemName: "server.rack").resizable().scaledToFit()
artwork.image.resizable().scaledToFit()
}
}
.foregroundStyle(.tint)
@@ -0,0 +1,26 @@
import Foundation
extension AppStrings {
public nonisolated enum HostIcons {
public static var server: LocalizedStringResource { "Server" }
public static var cloud: LocalizedStringResource { "Cloud" }
public static var code: LocalizedStringResource { "Code" }
public static var database: LocalizedStringResource { "Database" }
public static var storage: LocalizedStringResource { "Storage" }
public static var container: LocalizedStringResource { "Container" }
public static var network: LocalizedStringResource { "Network" }
public static var shield: LocalizedStringResource { "Shield" }
public static var homeLab: LocalizedStringResource { "Home Lab" }
public static var web: LocalizedStringResource { "Web" }
public static var api: LocalizedStringResource { "API" }
public static var compute: LocalizedStringResource { "Compute" }
public static var cluster: LocalizedStringResource { "Cluster" }
public static var router: LocalizedStringResource { "Router" }
public static var automation: LocalizedStringResource { "Automation" }
public static var monitoring: LocalizedStringResource { "Monitoring" }
public static var backup: LocalizedStringResource { "Backup" }
public static var media: LocalizedStringResource { "Media" }
public static var git: LocalizedStringResource { "Git" }
public static var virtualMachine: LocalizedStringResource { "Virtual Machine" }
}
}
@@ -3,7 +3,6 @@ import Foundation
extension AppStrings {
public nonisolated enum Vault {
public static var icon: LocalizedStringResource { "Icon" }
public static var serverIcon: LocalizedStringResource { "Server" }
public static var unknownIcon: LocalizedStringResource { "Unknown Icon" }
public static var addHost: LocalizedStringResource { "Add Host" }
@@ -76,7 +76,9 @@ struct MobileHostEditor: View {
}
},
content: {
HostOrganizationSection(host: $draft) {
HostOrganizationSection(
host: $draft, detectedDistribution: (library.host(draft.id) ?? draft).detectedDistribution
) {
Picker(AppStrings.Vault.group.swiftUIKey, selection: fields.groupChoice) {
Text(AppStrings.Common.none).tag(HostEditorChoice<HostGroup.ID>.unset)
ForEach(library.groups) { group in
@@ -320,11 +320,11 @@ final class MobileWorkspace: Identifiable {
self.advisoryTelnetPassword = advisoryTelnetPassword
self.externalSFTPResourcePath = externalSFTPResourcePath
self.externalSFTPMustBeDirectory = externalSFTPMustBeDirectory
let artwork = HostIconCatalog.resolvedArtwork(for: hosts.host(descriptor.hostID) ?? transientHost)
activitySession = WorkspaceActivityAttributes.Session(
id: descriptor.id, title: descriptor.title, kind: descriptor.kind.rawValue,
connected: false,
iconAsset: HostIconCatalog.resolvedEntry(
for: hosts.host(descriptor.hostID) ?? transientHost)?.asset)
iconAsset: artwork.assetName, iconSystemName: artwork.systemImageName)
if connectionOrigin.allowsPersistence, hosts.host(descriptor.hostID) != nil {
activitySession.recovery = .init(
sourceWorkspaceID: descriptor.id, hostID: descriptor.hostID, kind: descriptor.kind.rawValue)
@@ -1900,10 +1900,13 @@ final class MobileWorkspace: Identifiable {
} else {
distribution = host?.detectedDistribution
}
let iconAsset = HostIconCatalog.bundled.resolvedEntry(
overrideID: host?.iconOverrideID, distribution: distribution)?.asset
guard activitySession.iconAsset != iconAsset else { return }
activitySession.iconAsset = iconAsset
let artwork = HostIconCatalog.bundled.resolvedArtwork(
overrideID: host?.iconOverrideID, distribution: distribution)
guard
activitySession.iconAsset != artwork.assetName || activitySession.iconSystemName != artwork.systemImageName
else { return }
activitySession.iconAsset = artwork.assetName
activitySession.iconSystemName = artwork.systemImageName
didChangeState?(activitySession)
}
+21
View File
@@ -1477,6 +1477,27 @@ private actor MobileTestSFTPClient: SFTPClientProtocol {
}
@Suite struct WorkspaceHostIconTests {
@Test(arguments: HostGeneralIcon.allCases)
@MainActor func generalArtworkIsAvailableOnMobile(icon: HostGeneralIcon) {
#expect(UIImage(systemName: icon.systemImageName) != nil)
}
@Test func activityPreservesGeneralArtworkAndReadsLegacyPayloads() throws {
let session = WorkspaceActivityAttributes.Session(
id: UUID(), title: "Cloud", kind: "ssh", connected: true, iconSystemName: "cloud")
let data = try JSONEncoder().encode(session)
let restored = try JSONDecoder().decode(WorkspaceActivityAttributes.Session.self, from: data)
#expect(restored.iconSystemName == "cloud")
#expect(restored.iconAsset == nil)
var legacy = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
legacy.removeValue(forKey: "iconSystemName")
legacy["iconAsset"] = "HostOS-macos"
let oldSession = try JSONDecoder().decode(
WorkspaceActivityAttributes.Session.self, from: JSONSerialization.data(withJSONObject: legacy))
#expect(oldSession.iconSystemName == nil)
#expect(oldSession.iconAsset == "HostOS-macos")
}
@Test @MainActor func operatingSystemArtworkIsBundledOnMobile() {
#expect(HostIconCatalog.bundled.selectableEntries.first?.title == "AlmaLinux")
#expect(HostIconCatalog.bundled.selectableEntries.last?.title == "Zorin")
@@ -17,17 +17,11 @@ struct MobileHostIntegrationRenderingTests {
func iconPickerFollowsGroupAndOnlyChangesTheDraft(language: String) async throws {
let draft = HostArtworkDraft()
let controller = UIHostingController(
rootView: Form {
HostOrganizationSection(host: Binding(get: { draft.host }, set: { draft.host = $0 })) {
LabeledContent(AppStrings.Vault.group.swiftUIKey) { Text(verbatim: "Production") }
} tagsField: {
Text(AppStrings.Vault.tags)
}
}.environment(\.locale, Locale(identifier: language)))
rootView: HostArtworkForm(draft: draft).environment(\.locale, Locale(identifier: language)))
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
window.frame = CGRect(x: 0, y: 0, width: 390, height: 600)
window.frame = CGRect(x: 0, y: 0, width: min(scene.screen.bounds.width, 834), height: 600)
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
@@ -39,32 +33,84 @@ struct MobileHostIntegrationRenderingTests {
try await Task.sleep(for: .milliseconds(500))
let picker = try #require(
descendants(controller.view).compactMap { $0 as? UIButton }.first { $0.showsMenuAsPrimaryAction })
let actions = try await menuActions(in: picker, expectedCount: HostIconCatalog.bundled.entries.count + 2)
let generalTitles = HostGeneralIcon.allCases.map {
AppLocalization.string(String.LocalizationValue($0.title.key), locale: Locale(identifier: language))
}
let expectedGroups = [
[AppLocalization.string("Automatic", locale: Locale(identifier: language))],
generalTitles,
HostIconCatalog.bundled.selectableEntries.map(\.title),
]
let actions = try await menuActions(
in: picker, expectedCount: HostIconCatalog.bundled.entries.count + generalTitles.count + 1,
expectedGroups: expectedGroups)
#expect(
actions.prefix(2).map(\.title) == [
AppLocalization.string("Automatic", locale: Locale(identifier: language)),
AppLocalization.string("Server", locale: Locale(identifier: language)),
])
#expect(actions.dropFirst(2).map(\.title) == HostIconCatalog.bundled.selectableEntries.map(\.title))
#expect(Array(actions.dropFirst().prefix(generalTitles.count).map(\.title)) == generalTitles)
#expect(
actions.dropFirst(generalTitles.count + 1).map(\.title)
== HostIconCatalog.bundled.selectableEntries.map(\.title))
#expect(actions.allSatisfy { $0.image != nil })
#expect(actions.first?.state == .on)
#expect(actions.filter { $0.state == .on }.count == 1)
let automaticImage = try imagePixels(actions.first?.image)
try await captureIconPicker(window, language: language, name: "automatic")
picker.sendAction(actions[2])
#expect(draft.host.iconOverrideID == "cloud")
#expect(draft.host.detectedDistribution?.id == "macos")
let generalSelected = try await menuActions(in: picker, expectedCount: actions.count)
#expect(generalSelected[2].state == .on)
#expect(generalSelected.filter { $0.state == .on }.count == 1)
#expect(try imagePixels(generalSelected[0].image) == automaticImage)
try await captureIconPicker(window, language: language, name: "cloud")
picker.sendAction(try #require(actions.first { $0.title == "Windows" }))
#expect(draft.host.iconOverrideID == "windows")
#expect(draft.host.detectedDistribution?.id == "macos")
try await captureIconPicker(window, language: language, name: "windows")
let updated = try await menuActions(in: picker, expectedCount: actions.count)
picker.sendAction(updated[0])
#expect(draft.host.iconOverrideID == nil)
draft.host.detectedDistribution = nil
let undetected = try await menuActions(in: picker, expectedCount: actions.count)
#expect(try imagePixels(undetected[0].image) == imagePixels(undetected[1].image))
#expect(try imagePixels(undetected[0].image) != automaticImage)
try await captureIconPicker(window, language: language, name: "fallback")
draft.host.iconOverrideID = "future-icon"
let unknown = try await menuActions(in: picker, expectedCount: actions.count + 1)
#expect(unknown.last?.title == AppLocalization.string("Unknown Icon", locale: Locale(identifier: language)))
#expect(unknown.last?.state == .on)
#expect(unknown.filter { $0.state == .on }.count == 1)
#expect(try imagePixels(unknown.last?.image) == imagePixels(unknown[1].image))
#expect(draft.host.iconOverrideID == "future-icon")
}
private func imagePixels(_ image: UIImage?) throws -> Data {
let image = try #require(image)
let format = UIGraphicsImageRendererFormat()
format.scale = 2
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24), format: format)
return renderer.pngData { _ in
image.withTintColor(.black, renderingMode: .alwaysOriginal)
.draw(in: CGRect(x: 0, y: 0, width: 24, height: 24))
}
}
private func captureIconPicker(_ window: UIWindow, language: String, name: String) async throws {
for style in [UIUserInterfaceStyle.light, .dark] {
window.overrideUserInterfaceStyle = style
try await Task.sleep(for: .milliseconds(500))
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(300))
window.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
#expect(window.drawHierarchy(in: window.bounds, afterScreenUpdates: true))
}
let data = try #require(image.pngData())
let name = "host-icons-mobile-\(language)-\(style.rawValue).png"
Attachment.record(data, named: name)
try data.write(to: FileManager.default.temporaryDirectory.appending(path: name))
let filename = "host-icons-mobile-\(language)-\(name)-\(Int(window.bounds.width))-\(style.rawValue).png"
Attachment.record(data, named: filename)
try data.write(to: FileManager.default.temporaryDirectory.appending(path: filename))
}
let updated = try await menuActions(in: picker, expectedCount: actions.count)
picker.sendAction(updated[0])
#expect(draft.host.iconOverrideID == nil)
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
@@ -167,13 +213,18 @@ struct MobileHostIntegrationRenderingTests {
[view] + view.subviews.flatMap(descendants)
}
private func menuActions(in button: UIButton, expectedCount: Int = 3) async throws -> [UIAction] {
private func menuActions(
in button: UIButton, expectedCount: Int = 3, expectedGroups: [[String]]? = nil
) async throws -> [UIAction] {
button.performPrimaryAction()
try await Task.sleep(for: .milliseconds(500))
let interaction = try #require(button.contextMenuInteraction)
var actions: [UIAction] = []
interaction.updateVisibleMenu { menu in
actions = self.menuActions(menu)
if let expectedGroups {
#expect(self.actionGroups(menu) == expectedGroups)
}
return menu
}
interaction.dismissMenu()
@@ -189,6 +240,12 @@ struct MobileHostIntegrationRenderingTests {
return []
}
}
private func actionGroups(_ menu: UIMenu) -> [[String]] {
let actions = menu.children.compactMap { $0 as? UIAction }
if !actions.isEmpty { return [actions.map(\.title)] }
return menu.children.compactMap { $0 as? UIMenu }.flatMap(actionGroups)
}
}
@Observable @MainActor private final class HostArtworkDraft {
@@ -196,6 +253,20 @@ struct MobileHostIntegrationRenderingTests {
name: "Mac", host: "example.invalid", detectedDistribution: DetectedDistribution(id: "macos"))
}
private struct HostArtworkForm: View {
@Bindable var draft: HostArtworkDraft
var body: some View {
Form {
HostOrganizationSection(host: $draft.host, detectedDistribution: draft.host.detectedDistribution) {
LabeledContent(AppStrings.Vault.group.swiftUIKey) { Text(verbatim: "Production") }
} tagsField: {
Text(AppStrings.Vault.tags)
}
}
}
}
@Observable @MainActor private final class HostIntegrationDraft {
var mode = HostShellIntegrationMode.followGlobalSettings
}
+3 -1
View File
@@ -59,7 +59,9 @@ struct MobileSavedNameRenderingTests {
try await capture(
NavigationStack {
Form {
HostOrganizationSection(host: .constant(.init(name: "", host: "example.invalid"))) {
HostOrganizationSection(
host: .constant(.init(name: "", host: "example.invalid")), detectedDistribution: nil
) {
EmptyView()
} tagsField: {
EmptyView()
@@ -64,7 +64,7 @@ private struct MobileSingleLineHostTestForm: View {
var body: some View {
Form {
HostOrganizationSection(host: $draft.host) {
HostOrganizationSection(host: $draft.host, detectedDistribution: draft.host.detectedDistribution) {
EmptyView()
} tagsField: {
EmptyView()
@@ -37,6 +37,16 @@ struct MobileWorkspaceSessionTests {
.systemInfo(
RemoteSystemInfo(distributionID: "ubuntu", kernelName: "Linux")))
#expect(workspace.activitySession.iconAsset == "HostOS-windows")
for (choice, symbol) in [("cloud", "cloud"), ("database", "cylinder")] {
draft = try #require(app.library.host(host.id))
draft.iconOverrideID = choice
#expect(app.library.saveHost(draft))
for _ in 0..<50 where workspace.activitySession.iconSystemName != symbol {
try await Task.sleep(for: .milliseconds(10))
}
#expect(workspace.activitySession.iconAsset == nil)
#expect(workspace.activitySession.iconSystemName == symbol)
}
draft = try #require(app.library.host(host.id))
draft.iconOverrideID = nil
app.applyRemoteVault(.init(hosts: [draft]), keys: [.init(kind: .host, id: host.id)])
@@ -44,6 +54,7 @@ struct MobileWorkspaceSessionTests {
try await Task.sleep(for: .milliseconds(10))
}
#expect(workspace.activitySession.iconAsset == "LinuxDistro-ubuntu")
#expect(workspace.activitySession.iconSystemName == nil)
workspace.receiveShellEvent(.systemInfo(RemoteSystemInfo(kernelName: "DragonFly")))
#expect(workspace.activitySession.iconAsset == "HostOS-bsd")
#expect(app.library.host(host.id)?.detectedDistribution?.id == "dragonfly")
+7 -3
View File
@@ -41,11 +41,15 @@ struct WorkspaceLiveActivity: Widget {
.contentMargins(.horizontal, 20)
.contentMargins(.bottom, 12)
} compactLeading: {
HostSystemIcon(asset: context.state.sessions.first?.iconAsset, size: 18, showsBackground: false)
HostSystemIcon(
asset: context.state.sessions.first?.iconAsset,
systemImageName: context.state.sessions.first?.iconSystemName, size: 18, showsBackground: false)
} compactTrailing: {
Text(context.state.workspaceCount, format: .number).monospacedDigit()
} minimal: {
HostSystemIcon(asset: context.state.sessions.first?.iconAsset, size: 18, showsBackground: false)
HostSystemIcon(
asset: context.state.sessions.first?.iconAsset,
systemImageName: context.state.sessions.first?.iconSystemName, size: 18, showsBackground: false)
}
.widgetURL(context.state.destinationURL)
}
@@ -71,7 +75,7 @@ struct WorkspaceLiveActivity: Widget {
return HStack(spacing: 8) {
ForEach(visibleSessions) { session in
Link(destination: session.destinationURL) {
HostSystemIcon(asset: session.iconAsset)
HostSystemIcon(asset: session.iconAsset, systemImageName: session.iconSystemName)
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel(session.title)
+3 -1
View File
@@ -140,7 +140,9 @@ struct HostEditorView: View {
private var form: some View {
Form {
HostOrganizationSection(host: $draft) {
HostOrganizationSection(
host: $draft, detectedDistribution: (app.hostLibrary.host(draft.id) ?? draft).detectedDistribution
) {
Picker(AppStrings.Vault.group.swiftUIKey, selection: fields.groupChoice) {
Text(AppStrings.Common.none).tag(HostEditorChoice<HostGroup.ID>.unset)
ForEach(app.hostLibrary.groups) { group in
+59 -5
View File
@@ -7,6 +7,11 @@ import Testing
@Suite @MainActor
struct HostIconPresentationTests {
@Test(arguments: HostGeneralIcon.allCases)
func generalArtworkIsAvailableOnMac(icon: HostGeneralIcon) {
#expect(NSImage(systemSymbolName: icon.systemImageName, accessibilityDescription: nil) != nil)
}
@Test(arguments: [
("artix", "Artix Linux"), ("asahi", "Asahi Linux"), ("cachyos", "CachyOS"),
("devuan", "Devuan"), ("garuda", "Garuda Linux"), ("mx", "MX Linux"),
@@ -61,12 +66,14 @@ struct HostIconPresentationTests {
@Suite(.requiresVisibleWindow, .serialized) @MainActor
struct HostIconRenderingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func rendersOrganizationFormInBothAppearances(language: String) async throws {
let host = TermindDomain.Host(name: "Mac", host: "example.invalid", iconOverrideID: "windows")
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [String?.none, "cloud", "windows", "future-icon"])
func rendersOrganizationFormInBothAppearances(language: String, selection: String?) async throws {
let host = TermindDomain.Host(
name: "Mac", host: "example.invalid", detectedDistribution: DetectedDistribution(id: "macos"),
iconOverrideID: selection)
let controller = NSHostingController(
rootView: Form {
HostOrganizationSection(host: .constant(host)) {
HostOrganizationSection(host: .constant(host), detectedDistribution: host.detectedDistribution) {
LabeledContent(AppStrings.Vault.group.swiftUIKey) { Text(verbatim: "Production") }
} tagsField: {
Text(AppStrings.Vault.tags)
@@ -92,12 +99,59 @@ struct HostIconRenderingTests {
window.appearance = NSAppearance(named: appearance)
try await Task.sleep(for: .milliseconds(300))
controller.view.layoutSubtreeIfNeeded()
let picker = try #require(
descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.image != nil })
if selection == nil || selection == "windows" {
#expect(picker.image?.size == NSSize(width: 16, height: 16))
}
if language == "en", selection == nil, appearance == .aqua {
let capture = HostIconMenuCapture()
NotificationCenter.default.addObserver(
capture, selector: #selector(HostIconMenuCapture.menuDidBeginTracking(_:)),
name: NSMenu.didBeginTrackingNotification, object: nil)
picker.performClick(nil)
NotificationCenter.default.removeObserver(capture)
let menu = try #require(capture.menu)
let groups = menu.items.split(whereSeparator: \.isSeparatorItem)
#expect(
groups.map { $0.map(\.title) } == [
["Automatic"],
HostGeneralIcon.allCases.map {
AppLocalization.string(
String.LocalizationValue($0.title.key), locale: Locale(identifier: "en"))
},
HostIconCatalog.bundled.selectableEntries.map(\.title),
])
let automatic = try #require(groups.first?.first)
#expect(automatic.state == .on)
#expect(automatic.image?.size == NSSize(width: 16, height: 16))
let systemItems = try #require(groups.last)
#expect(systemItems.allSatisfy { $0.image?.size == NSSize(width: 16, height: 16) })
}
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
let name = "host-icons-desktop-\(language)-\(appearance.rawValue).png"
let name = "host-icons-desktop-\(language)-\(selection ?? "automatic")-\(appearance.rawValue).png"
Attachment.record(data, named: name)
try data.write(to: FileManager.default.temporaryDirectory.appending(path: name))
}
}
private func descendants(_ view: NSView) -> [NSView] {
[view] + view.subviews.flatMap(descendants)
}
}
@MainActor
private final class HostIconMenuCapture: NSObject {
var menu: NSMenu?
@objc func menuDidBeginTracking(_ notification: Notification) {
guard let menu = notification.object as? NSMenu else { return }
self.menu = menu
let timer = Timer(timeInterval: 0.1, repeats: false) { [self] _ in
MainActor.assumeIsolated { self.menu?.cancelTracking() }
}
RunLoop.main.add(timer, forMode: .common)
}
}
+3 -1
View File
@@ -18,7 +18,9 @@ struct ModelProviderRenderingTests {
defer { app.shutdownForTermination() }
try await capture(
Form {
HostOrganizationSection(host: .constant(.init(name: "", host: "example.invalid"))) {
HostOrganizationSection(
host: .constant(.init(name: "", host: "example.invalid")), detectedDistribution: nil
) {
EmptyView()
} tagsField: {
EmptyView()
+1 -1
View File
@@ -116,7 +116,7 @@ private struct SingleLineHostTestForm: View {
var body: some View {
Form {
HostOrganizationSection(host: $draft.host) {
HostOrganizationSection(host: $draft.host, detectedDistribution: draft.host.detectedDistribution) {
EmptyView()
} tagsField: {
EmptyView()
@@ -18,6 +18,7 @@ nonisolated struct WorkspaceActivityAttributes: ActivityAttributes {
var kind: String
var connected: Bool
var iconAsset: String? = nil
var iconSystemName: String? = nil
var recovery: Recovery? = nil
var destinationURL: URL {