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:
@@ -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 }
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "apple.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "arch.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "debian.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "fedora.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "linux.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "nixos.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "opensuse.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "steam.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "ubuntu.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "windows.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user