feat(client/apple): host cards wear the OS mark, and the store learns it

`DiscoveredHost` reads the new `os=` TXT (sanitized in PunktfunkShared's
OsChain.swift — the pure grammar + walk, mirrored from pf-client-core so every
platform resolves identically, and dependency-free so the widget could use it);
`StoredHost` appends optional `osChain` per the frozen app↔widget contract
(optional + appended last; legacy JSON still decodes, pinned by the round-trip
tests), and `HostStore.updateOsChain` learns it beside the MACs.

The art rides PunktfunkKit's proven resource path (the fonts precedent): ten
template vector imagesets in Resources/OsIcons.xcassets, compiled by the Xcode
build into the Kit bundle's Assets.car (verified: all ten resolve in the built
app) and tinting via foregroundStyle like an SF Symbol. Saved and discovered
cards lead their status line with the mark; a host that advertises nothing
renders exactly as before. GamepadHome tile + widget marks are follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:58:33 +02:00
co-authored by Claude Fable 5
parent 944c03dd32
commit 88c8688b47
30 changed files with 244 additions and 4 deletions
+4
View File
@@ -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.
@@ -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)
@@ -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))
@@ -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).
@@ -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()
@@ -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<String> = [
"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) }
}
@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "apple.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "arch.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "debian.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "fedora.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "linux.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "nixos.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "opensuse.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "steam.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "ubuntu.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "windows.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -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)
}
}
}
@@ -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/<family>/<id>`, ...) 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 }
@@ -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 {