From b139fbc8416bcd9cbbc5f78a53e02f3fb480c72b Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 00:54:34 +0800 Subject: [PATCH] feat(hosts): expand icons and fix automatic previews --- FrameworkTests/HostIconTests.swift | 47 +- Frameworks/Resources/Localizable.xcstrings | 738 ++++++++++++++++++ .../HostConnectionFields.swift | 5 +- .../HostDistributionIcon.swift | 2 +- .../TermindPresentation/HostGeneralIcon.swift | 82 ++ .../TermindPresentation/HostIconArtwork.swift | 21 + .../TermindPresentation/HostIconCatalog.swift | 14 + .../TermindPresentation/HostIconPicker.swift | 100 ++- .../TermindPresentation/HostSystemIcon.swift | 26 +- .../Strings/HostIcons.swift | 26 + .../TermindPresentation/Strings/Vault.swift | 1 - .../HostEditor/MobileHostEditor.swift | 4 +- TFI/Features/Workspaces/MobileWorkspace.swift | 15 +- TFITests/MobileAppTests.swift | 21 + .../MobileHostIntegrationRenderingTests.swift | 109 ++- TFITests/MobileSavedNameRenderingTests.swift | 4 +- .../MobileSingleLineFieldEditingTests.swift | 2 +- TFITests/MobileWorkspaceSessionTests.swift | 11 + TFIWidgets/WorkspaceLiveActivity.swift | 10 +- TFM/Features/HostEditor/HostEditorView.swift | 4 +- TFMTests/HostIconPresentationTests.swift | 64 +- TFMTests/ModelProviderRenderingTests.swift | 4 +- TFMTests/SingleLineFieldEditingTests.swift | 2 +- .../WorkspaceActivityAttributes.swift | 1 + 24 files changed, 1241 insertions(+), 72 deletions(-) create mode 100644 Frameworks/TermindPresentation/HostGeneralIcon.swift create mode 100644 Frameworks/TermindPresentation/HostIconArtwork.swift create mode 100644 Frameworks/TermindPresentation/Strings/HostIcons.swift diff --git a/FrameworkTests/HostIconTests.swift b/FrameworkTests/HostIconTests.swift index 87dabe40..e5dfe1b2 100644 --- a/FrameworkTests/HostIconTests.swift +++ b/FrameworkTests/HostIconTests.swift @@ -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) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index d0110db3..4f224828 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift index 54c15222..96df713e 100644 --- a/Frameworks/TermindPresentation/HostConnectionFields.swift +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -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: View { @Binding private var host: Host + private let detectedDistribution: DetectedDistribution? private let groupField: GroupField private let tagsField: TagsField public init( host: Binding, + 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: 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) } diff --git a/Frameworks/TermindPresentation/HostDistributionIcon.swift b/Frameworks/TermindPresentation/HostDistributionIcon.swift index 1bec381d..bfd232c9 100644 --- a/Frameworks/TermindPresentation/HostDistributionIcon.swift +++ b/Frameworks/TermindPresentation/HostDistributionIcon.swift @@ -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) } } diff --git a/Frameworks/TermindPresentation/HostGeneralIcon.swift b/Frameworks/TermindPresentation/HostGeneralIcon.swift new file mode 100644 index 00000000..ff44ae14 --- /dev/null +++ b/Frameworks/TermindPresentation/HostGeneralIcon.swift @@ -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" + } + } +} diff --git a/Frameworks/TermindPresentation/HostIconArtwork.swift b/Frameworks/TermindPresentation/HostIconArtwork.swift new file mode 100644 index 00000000..30fc230a --- /dev/null +++ b/Frameworks/TermindPresentation/HostIconArtwork.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindPresentation/HostIconCatalog.swift b/Frameworks/TermindPresentation/HostIconCatalog.swift index d702a44b..0ccfb0cc 100644 --- a/Frameworks/TermindPresentation/HostIconCatalog.swift +++ b/Frameworks/TermindPresentation/HostIconCatalog.swift @@ -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( diff --git a/Frameworks/TermindPresentation/HostIconPicker.swift b/Frameworks/TermindPresentation/HostIconPicker.swift index d1f2a1f0..a7443dfd 100644 --- a/Frameworks/TermindPresentation/HostIconPicker.swift +++ b/Frameworks/TermindPresentation/HostIconPicker.swift @@ -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) { + public init(selection: Binding, 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 } } diff --git a/Frameworks/TermindPresentation/HostSystemIcon.swift b/Frameworks/TermindPresentation/HostSystemIcon.swift index c01fecae..6f96d003 100644 --- a/Frameworks/TermindPresentation/HostSystemIcon.swift +++ b/Frameworks/TermindPresentation/HostSystemIcon.swift @@ -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) diff --git a/Frameworks/TermindPresentation/Strings/HostIcons.swift b/Frameworks/TermindPresentation/Strings/HostIcons.swift new file mode 100644 index 00000000..d52a4b67 --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/HostIcons.swift @@ -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" } + } +} diff --git a/Frameworks/TermindPresentation/Strings/Vault.swift b/Frameworks/TermindPresentation/Strings/Vault.swift index d4031b7a..678496af 100644 --- a/Frameworks/TermindPresentation/Strings/Vault.swift +++ b/Frameworks/TermindPresentation/Strings/Vault.swift @@ -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" } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 34e5d0fe..2ad4a93c 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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.unset) ForEach(library.groups) { group in diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 7ef90754..74e7ebe8 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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) } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 6fedc367..eb1845af 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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") diff --git a/TFITests/MobileHostIntegrationRenderingTests.swift b/TFITests/MobileHostIntegrationRenderingTests.swift index 39fed0be..b57d7eb3 100644 --- a/TFITests/MobileHostIntegrationRenderingTests.swift +++ b/TFITests/MobileHostIntegrationRenderingTests.swift @@ -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 } diff --git a/TFITests/MobileSavedNameRenderingTests.swift b/TFITests/MobileSavedNameRenderingTests.swift index 6cedf8f0..9bdcfc7b 100644 --- a/TFITests/MobileSavedNameRenderingTests.swift +++ b/TFITests/MobileSavedNameRenderingTests.swift @@ -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() diff --git a/TFITests/MobileSingleLineFieldEditingTests.swift b/TFITests/MobileSingleLineFieldEditingTests.swift index 48852006..47c85369 100644 --- a/TFITests/MobileSingleLineFieldEditingTests.swift +++ b/TFITests/MobileSingleLineFieldEditingTests.swift @@ -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() diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 1aba8f1e..3de0e961 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -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") diff --git a/TFIWidgets/WorkspaceLiveActivity.swift b/TFIWidgets/WorkspaceLiveActivity.swift index 2009f0f6..318a4acc 100644 --- a/TFIWidgets/WorkspaceLiveActivity.swift +++ b/TFIWidgets/WorkspaceLiveActivity.swift @@ -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) diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index b4685af5..267d9fb6 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -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.unset) ForEach(app.hostLibrary.groups) { group in diff --git a/TFMTests/HostIconPresentationTests.swift b/TFMTests/HostIconPresentationTests.swift index 07a6d1fd..c4727077 100644 --- a/TFMTests/HostIconPresentationTests.swift +++ b/TFMTests/HostIconPresentationTests.swift @@ -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) + } } diff --git a/TFMTests/ModelProviderRenderingTests.swift b/TFMTests/ModelProviderRenderingTests.swift index 0219afa4..2a3f1d6d 100644 --- a/TFMTests/ModelProviderRenderingTests.swift +++ b/TFMTests/ModelProviderRenderingTests.swift @@ -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() diff --git a/TFMTests/SingleLineFieldEditingTests.swift b/TFMTests/SingleLineFieldEditingTests.swift index e435a651..e062ed00 100644 --- a/TFMTests/SingleLineFieldEditingTests.swift +++ b/TFMTests/SingleLineFieldEditingTests.swift @@ -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() diff --git a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift index f8b8af49..752491e9 100644 --- a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift +++ b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift @@ -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 {