feat(hosts): expand icons and fix automatic previews
This commit is contained in:
1 parent
21accf8ae5
commit
b139fbc841
24 files changed
+1241
-72
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user