diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 3fd28e27..24655d13 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -34,6 +34,10 @@ let package = Package( // Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website. // Registered with Core Text at first use; see BrandFont.swift. .copy("Resources/Fonts"), + // The host cards' OS marks (template vector imagesets derived from the + // assets/os-icons masters — FA brands CC BY 4.0 + Simple Icons CC0, see that + // README). `.process` compiles the catalog; loaded via OsIcon.swift. + .process("Resources/OsIcons.xcassets"), ], linkerSettings: [ // Rust staticlib system deps. diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 175a282f..e1264200 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -916,6 +916,7 @@ struct ContentView: View { private func prepareWake(for host: StoredHost) { if let live = discovery.hosts.first(where: { host.matches($0) }) { store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform + store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark } else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty { // Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the // dial times out. With auto-wake off, connects go straight through (no packet). @@ -970,7 +971,8 @@ struct ContentView: View { guard !model.isBusy else { return } let host = StoredHost( name: d.name, address: d.host, port: d.port, - macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses) + macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses, + osChain: d.osChain.isEmpty ? nil : d.osChain) store.add(host) if d.allowsTofu { connect(host, allowTofu: true) diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index 24be520a..1aee3cb5 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -295,6 +295,16 @@ struct HostCardView: View { /// certificate is pinned (the lock state, spelled out). @ViewBuilder private func statusRow(_ m: CardMetrics) -> some View { HStack(spacing: 6) { + // The host's OS mark leads the row (template asset — tints like an SF Symbol); + // absent entirely for a host that never advertised one, so those cards render + // exactly as they always did. + if let mark = osIconImage(for: host.osChain) { + mark + .resizable() + .scaledToFit() + .frame(width: m.status + 2, height: m.status + 2) + .accessibilityLabel(host.osChain ?? "") + } RoundedRectangle(cornerRadius: 1.5) .fill(isOnline ? Color.green : Color.secondary.opacity(0.4)) .frame(width: 6, height: 6) @@ -365,6 +375,14 @@ struct DiscoveredCardView: View { .foregroundStyle(.secondary) .lineLimit(1) HStack(spacing: 6) { + // Same leading OS mark as a saved card's status row — live from the advert. + if let mark = osIconImage(for: discovered.osChain) { + mark + .resizable() + .scaledToFit() + .frame(width: m.status + 2, height: m.status + 2) + .accessibilityLabel(discovered.osChain) + } Image(systemName: discovered.requiresPairing ? "lock.fill" : "antenna.radiowaves.left.and.right") .font(.system(size: m.status)) diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 15bab6f8..29ecebce 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -153,6 +153,15 @@ final class HostStore: ObservableObject { hosts[i].macAddresses = macs } + /// Learn/refresh this host's OS-identity chain from its live advert — same contract as + /// [`updateMacs`]: no-op when empty or unchanged, so discovery ticks don't churn UserDefaults. + func updateOsChain(_ hostID: UUID, chain: String) { + guard !chain.isEmpty, + let i = hosts.firstIndex(where: { $0.id == hostID }), + hosts[i].osChain != chain else { return } + hosts[i].osChain = chain + } + /// Bind this host to a settings profile, or to "Default settings" (nil) — the ONLY way the /// default changes. A one-off "Connect with ▸" deliberately never lands here (§5.2: /// predictable, not sticky). diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index 301e23d1..d7ccb78c 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -13,6 +13,7 @@ #if canImport(Network) import Foundation import Network +import PunktfunkShared /// A punktfunk/1 host found on the LAN. `fingerprintHex` is advisory (see file header). public struct DiscoveredHost: Identifiable, Sendable, Equatable { @@ -37,6 +38,10 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { /// value only makes a wake fail — the magic packet is inert and the fingerprint still gates /// the connection). public let macAddresses: [String] + /// The host's OS-identity chain (mDNS `os` TXT, e.g. `linux/fedora/bazzite`), sanitized + /// (`sanitizeOsChain`) — drives the host card's OS mark and is persisted like the MACs. + /// Empty when not advertised (older host). Advisory/unauthenticated like the rest. + public let osChain: String } @MainActor @@ -118,6 +123,7 @@ public final class HostDiscovery: ObservableObject { var pair: String? var id: String? var macs: [String] = [] + var osChain = "" if case let .bonjour(txt) = result.metadata { fp = Self.entry(txt, "fp") pair = Self.entry(txt, "pair") @@ -126,6 +132,7 @@ public final class HostDiscovery: ObservableObject { .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } + osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "") } // Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS // mDNS responder often answers AAAA for its hostname even though the punktfunk host stack @@ -149,7 +156,8 @@ public final class HostDiscovery: ObservableObject { id: (id?.isEmpty == false) ? id! : name, name: name, host: address, port: port.rawValue, fingerprintHex: fp, requiresPairing: pair == "required", - allowsTofu: pair == "optional", macAddresses: macs) + allowsTofu: pair == "optional", macAddresses: macs, + osChain: osChain) self.publish() } conn.cancel() diff --git a/clients/apple/Sources/PunktfunkKit/OsIcon.swift b/clients/apple/Sources/PunktfunkKit/OsIcon.swift new file mode 100644 index 00000000..b75df6eb --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/OsIcon.swift @@ -0,0 +1,24 @@ +// The host cards' OS marks: template vector imagesets in Resources/OsIcons.xcassets +// (derived from the repo's assets/os-icons masters — Font Awesome Free brands CC BY 4.0 + +// Simple Icons CC0; provenance in that directory's README), resolved from the host's +// OS-identity chain via PunktfunkShared's `osIconTokens` walk. Template rendering means +// they tint with `foregroundStyle` like an SF Symbol. + +import PunktfunkShared +import SwiftUI + +/// The icon tokens this client ships art for. A distro without its own mark (Bazzite, +/// CachyOS, ...) degrades to its family's and finally to Tux via the chain walk. +private let osIconTokensShipped: Set = [ + "windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos", + "opensuse", +] + +/// The mark for an OS-identity chain (`linux/fedora/bazzite`, ...), or nil — no view at +/// all — when the host doesn't advertise one / nothing in the chain is recognized, so +/// those cards render exactly as they did before the field existed. +public func osIconImage(for chain: String?) -> Image? { + osIconTokens(chain) + .first(where: osIconTokensShipped.contains) + .map { Image("os-\($0)", bundle: .module) } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-apple.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-apple.imageset/Contents.json new file mode 100644 index 00000000..c5e156c2 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-apple.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "apple.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-apple.imageset/apple.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-apple.imageset/apple.pdf new file mode 100644 index 00000000..103f1801 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-apple.imageset/apple.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-arch.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-arch.imageset/Contents.json new file mode 100644 index 00000000..1c70f182 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-arch.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "arch.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-arch.imageset/arch.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-arch.imageset/arch.pdf new file mode 100644 index 00000000..9d888761 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-arch.imageset/arch.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-debian.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-debian.imageset/Contents.json new file mode 100644 index 00000000..f7b67a40 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-debian.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "debian.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-debian.imageset/debian.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-debian.imageset/debian.pdf new file mode 100644 index 00000000..300d72c8 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-debian.imageset/debian.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-fedora.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-fedora.imageset/Contents.json new file mode 100644 index 00000000..8d9e5b77 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-fedora.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "fedora.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-fedora.imageset/fedora.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-fedora.imageset/fedora.pdf new file mode 100644 index 00000000..74f4dd11 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-fedora.imageset/fedora.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-linux.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-linux.imageset/Contents.json new file mode 100644 index 00000000..a63cc240 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-linux.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "linux.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-linux.imageset/linux.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-linux.imageset/linux.pdf new file mode 100644 index 00000000..0a5025e4 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-linux.imageset/linux.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-nixos.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-nixos.imageset/Contents.json new file mode 100644 index 00000000..2b6356ac --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-nixos.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "nixos.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-nixos.imageset/nixos.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-nixos.imageset/nixos.pdf new file mode 100644 index 00000000..76f75414 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-nixos.imageset/nixos.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-opensuse.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-opensuse.imageset/Contents.json new file mode 100644 index 00000000..5e5ded45 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-opensuse.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "opensuse.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-opensuse.imageset/opensuse.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-opensuse.imageset/opensuse.pdf new file mode 100644 index 00000000..a01bed03 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-opensuse.imageset/opensuse.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-steam.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-steam.imageset/Contents.json new file mode 100644 index 00000000..0efde325 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-steam.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "steam.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-steam.imageset/steam.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-steam.imageset/steam.pdf new file mode 100644 index 00000000..197d0e1d Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-steam.imageset/steam.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-ubuntu.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-ubuntu.imageset/Contents.json new file mode 100644 index 00000000..f5aa7661 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-ubuntu.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "ubuntu.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-ubuntu.imageset/ubuntu.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-ubuntu.imageset/ubuntu.pdf new file mode 100644 index 00000000..156db4e3 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-ubuntu.imageset/ubuntu.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-windows.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-windows.imageset/Contents.json new file mode 100644 index 00000000..7060c6ae --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-windows.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "windows.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-windows.imageset/windows.pdf b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-windows.imageset/windows.pdf new file mode 100644 index 00000000..5511f150 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/OsIcons.xcassets/os-windows.imageset/windows.pdf differ diff --git a/clients/apple/Sources/PunktfunkShared/OsChain.swift b/clients/apple/Sources/PunktfunkShared/OsChain.swift new file mode 100644 index 00000000..a190e2eb --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/OsChain.swift @@ -0,0 +1,43 @@ +// The client half of the host's OS-identity advertisement (mDNS `os` TXT — producer: +// punktfunk-host's osinfo.rs): sanitize the untrusted chain once, and turn it into the +// icon-lookup order every surface walks. Mirrors pf-client-core's `os.rs` (and the Kotlin +// port) so all platforms resolve identically; in the dependency-free shared module so the +// widget could use it too. The Image lookup itself lives in PunktfunkKit (OsIcon.swift). + +import Foundation + +/// Reduce a raw `os` TXT value (e.g. `linux/fedora/bazzite`) to the trusted grammar: +/// lowercase slash-separated tokens of `[a-z0-9._-]`, each ≤ 32 chars, at most 5. mDNS is +/// unauthenticated input — anything outside the grammar is dropped, and a value that +/// sanitizes to nothing becomes `""` (same rendering as a host that advertises no `os`). +public func sanitizeOsChain(_ raw: String) -> String { + raw.lowercased() + .split(separator: "/", omittingEmptySubsequences: true) + .map { token -> String in + let kept = token.unicodeScalars.filter { s in + (0x61...0x7A).contains(s.value) // a-z + || (0x30...0x39).contains(s.value) // 0-9 + || s == "." || s == "_" || s == "-" + } + return String(String.UnicodeScalarView(kept.prefix(32))) + } + .filter { !$0.isEmpty } + .prefix(5) + .joined(separator: "/") +} + +/// The icon-lookup order for a chain: sanitized tokens most-specific-first, with brand +/// aliases applied (`macos` → `apple` art, `steamos` → `steam` art). A surface takes the +/// first token it has art for; empty means "no OS icon" (older host / garbage advert). +public func osIconTokens(_ chain: String?) -> [String] { + sanitizeOsChain(chain ?? "") + .split(separator: "/") + .reversed() + .map { + switch $0 { + case "macos": return "apple" + case "steamos": return "steam" + default: return String($0) + } + } +} diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift index 46697a33..35ef7907 100644 --- a/clients/apple/Sources/PunktfunkShared/StoredHost.swift +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -53,12 +53,17 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable { /// appended last for the same widget-contract reason as the rest; hosts saved before it /// existed have none, and keep their stored order, which IS the order they were added in. public var addedAt: Date? + /// The host's OS-identity chain (`windows` | `linux//`, ...) learned from its + /// mDNS `os` TXT while online, so the card's OS mark survives the host going to sleep. + /// Optional and appended last for the same widget-contract reason; nil until first learned. + public var osChain: String? public init( id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777, pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil, macAddresses: [String]? = nil, clipboardSync: Bool? = nil, - profileID: String? = nil, pinnedProfileIDs: [String]? = nil, addedAt: Date? = nil + profileID: String? = nil, pinnedProfileIDs: [String]? = nil, addedAt: Date? = nil, + osChain: String? = nil ) { self.id = id self.name = name @@ -72,6 +77,7 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable { self.profileID = profileID self.pinnedProfileIDs = pinnedProfileIDs self.addedAt = addedAt + self.osChain = osChain } public var displayName: String { name.isEmpty ? address : name } diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift index 42b2cee5..8926b995 100644 --- a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -25,11 +25,13 @@ final class SharedFoundationTests: XCTestCase { lastConnected: Date(timeIntervalSince1970: 1_700_000_000), mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true, profileID: "a1b2c3d4e5f6", pinnedProfileIDs: ["0f0f0f0f0f0f"], - addedAt: Date(timeIntervalSince1970: 1_600_000_000)) + addedAt: Date(timeIntervalSince1970: 1_600_000_000), + osChain: "linux/fedora/bazzite") let data = try JSONEncoder().encode(host) let decoded = try JSONDecoder().decode(StoredHost.self, from: data) XCTAssertEqual(decoded, host) + XCTAssertEqual(decoded.osChain, "linux/fedora/bazzite") } /// Older saved hosts predate `mgmtPort`/`macAddresses` — and now `profileID`/ @@ -51,6 +53,7 @@ final class SharedFoundationTests: XCTestCase { XCTAssertNil(decoded.profileID) XCTAssertNil(decoded.pinnedProfileIDs) XCTAssertNil(decoded.addedAt) + XCTAssertNil(decoded.osChain) // Resolvers fall back cleanly. XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort) XCTAssertEqual(decoded.wakeMacs, []) @@ -62,6 +65,26 @@ final class SharedFoundationTests: XCTestCase { XCTAssertEqual(host.displayName, "10.0.0.9") } + // MARK: - OS-identity chain (mDNS `os` TXT → card icon walk) + + /// Mirrors pf-client-core's `os.rs` tests — the two implementations must agree on the + /// grammar (lowercase `[a-z0-9._-]` tokens, ≤ 32 chars each, ≤ 5 of them) and the walk + /// order (most-specific-first, `macos`→`apple`, `steamos`→`steam`). + func testOsChainSanitizeAndWalk() { + XCTAssertEqual(sanitizeOsChain("linux/fedora/bazzite"), "linux/fedora/bazzite") + XCTAssertEqual(sanitizeOsChain("Linux/Fe do!ra"), "linux/fedora") + XCTAssertEqual(sanitizeOsChain("///"), "") + XCTAssertEqual(sanitizeOsChain("a/b/c/d/e/f/g"), "a/b/c/d/e") + XCTAssertEqual(sanitizeOsChain(String(repeating: "x", count: 80)), + String(repeating: "x", count: 32)) + + XCTAssertEqual(osIconTokens("linux/fedora/bazzite"), ["bazzite", "fedora", "linux"]) + XCTAssertEqual(osIconTokens("linux/arch/steamos"), ["steam", "arch", "linux"]) + XCTAssertEqual(osIconTokens("macos"), ["apple"]) + XCTAssertEqual(osIconTokens(nil), []) + XCTAssertEqual(osIconTokens("!!!"), []) + } + // MARK: - DeepLink grammar (the cross-language vector file) private struct VectorFile: Decodable {