diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index de51317a..24be520a 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -325,7 +325,7 @@ struct ProfileChip: View { var prominent = false var body: some View { - let tint = profile.accentColor ?? Color.brand + let tint = profile.accentColor return HStack(spacing: 5) { Circle() .fill(tint) @@ -343,20 +343,6 @@ struct ProfileChip: View { } } -extension StreamProfile { - /// The `#RRGGBB` accent as a colour, or nil when the profile hasn't been given one (the - /// schema reserves the field; every surface falls back to the brand tint). - var accentColor: Color? { - guard let accent, accent.hasPrefix("#"), accent.count == 7, - let value = UInt32(accent.dropFirst(), radix: 16) - else { return nil } - return Color( - red: Double((value >> 16) & 0xFF) / 255, - green: Double((value >> 8) & 0xFF) / 255, - blue: Double(value & 0xFF) / 255) - } -} - /// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border /// distinguish it from saved cards; tapping saves it and connects (or pairs, if required). struct DiscoveredCardView: View { diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 80a128da..67a21962 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -91,7 +91,8 @@ struct StreamHUDView: View { if let profile = model.settings.profileName { Text("· \(profile)") .font(.system(.caption, design: .monospaced)) - .foregroundStyle(Color.accentColor) + .foregroundStyle( + Color(hex: model.settings.profileAccent ?? "") ?? Color.accentColor) .lineLimit(1) } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift new file mode 100644 index 00000000..2502fae5 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift @@ -0,0 +1,276 @@ +// The About page — the app's identity card, on the shape every Mac user already knows from an +// About panel: the icon, the name, the version, and then the ways out (documentation, community, +// source, licenses). It replaced a bare header over an 885 KB wall of license text, which answered +// the one question nobody opens About to ask. +// +// The license wall itself is still `AcknowledgementsView`; About links to it — pushed on +// iOS/tvOS, where this page lives in a navigation stack, and as a sheet on macOS, where the +// preferences TabView has no stack to push onto. + +import PunktfunkKit +import SwiftUI + +struct AboutView: View { + /// Where the product actually lives. Kept here rather than scattered through the view so the + /// three of them can be checked against the README in one glance. + private enum Destination { + static let docs = URL(string: "https://docs.punktfunk.unom.io")! + static let community = URL(string: "https://discord.gg/kaPNvzMuGU")! + static let source = URL(string: "https://git.unom.io/unom/punktfunk")! + } + + private static let tagline = + "Low-latency desktop and game streaming with first-class Linux and Windows hosts." + + #if os(macOS) + @State private var showAcknowledgements = false + #endif + + var body: some View { + #if os(tvOS) + tvBody + #else + Form { + Section { + identity + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + // The header is a statement, not a control: no separator, no row insets + // fighting the centring. + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + Section { + linkRow("Documentation", systemImage: "book", url: Destination.docs) + linkRow("Community", systemImage: "bubble.left.and.bubble.right", + url: Destination.community) + linkRow("Source Code", systemImage: "chevron.left.forwardslash.chevron.right", + url: Destination.source) + } + Section { + acknowledgementsRow + } footer: { + Text("Punktfunk is free software under MIT or Apache-2.0.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + #if os(macOS) + .sheet(isPresented: $showAcknowledgements) { + NavigationStack { + AcknowledgementsView() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showAcknowledgements = false } + } + } + } + .frame(width: 640, height: 560) + } + #endif + #endif + } + + // MARK: - Identity + + /// Icon, name, version, tagline — centred, in that order, at the sizes an About panel uses. + private var identity: some View { + VStack(spacing: 10) { + AppIconView(side: Self.iconSide) + Text("Punktfunk") + .font(.geist(Self.nameFont, .bold, relativeTo: .largeTitle)) + Text(Self.versionLine) + .font(.geist(Self.versionFont, relativeTo: .callout)) + .monospacedDigit() + .foregroundStyle(.secondary) + .modifier(SelectableIfPossible()) + Text(Self.tagline) + .font(.geist(Self.taglineFont, relativeTo: .footnote)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 320) + .padding(.top, 2) + } + } + + /// "Version 0.21.0 (1)" — the build number only when it says something the version doesn't. + /// A bug report is worth more with it, which is why it's selectable above. + private static var versionLine: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "—" + let build = info?["CFBundleVersion"] as? String + guard let build, !build.isEmpty, build != short else { return "Version \(short)" } + return "Version \(short) (\(build))" + } + + // MARK: - Rows + + #if !os(tvOS) + private func linkRow(_ title: String, systemImage: String, url: URL) -> some View { + Link(destination: url) { + HStack { + Label(title, systemImage: systemImage) + Spacer(minLength: 8) + Image(systemName: "arrow.up.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + // The row already announces itself as a link. + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + #if os(macOS) + // A Link in a grouped Form renders as raw blue body text on macOS; the row is the button. + .buttonStyle(.plain) + #endif + .foregroundStyle(.primary) + } + + @ViewBuilder private var acknowledgementsRow: some View { + #if os(macOS) + Button { + showAcknowledgements = true + } label: { + HStack { + Label("Acknowledgements", systemImage: "text.document") + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + #else + NavigationLink { + AcknowledgementsView() + } label: { + Label("Acknowledgements", systemImage: "text.document") + } + #endif + } + #endif + + // MARK: - tvOS + + #if os(tvOS) + /// tvOS has no browser and no `openURL`, so the addresses are text to read off the screen + /// rather than links to nowhere. Acknowledgements still pushes — that one is in the app. + private var tvBody: some View { + ScrollView { + VStack(spacing: 28) { + identity + VStack(spacing: 10) { + tvAddress("Documentation", Destination.docs) + tvAddress("Community", Destination.community) + tvAddress("Source code", Destination.source) + } + NavigationLink("Acknowledgements") { AcknowledgementsView() } + Text("Punktfunk is free software under MIT or Apache-2.0.") + .font(.geist(20, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 1000) + .frame(maxWidth: .infinity) + .padding(60) + } + .navigationTitle("About") + } + + private func tvAddress(_ title: String, _ url: URL) -> some View { + VStack(spacing: 2) { + Text(title) + .font(.geist(22, .semibold, relativeTo: .headline)) + Text(url.absoluteString.replacingOccurrences(of: "https://", with: "")) + .font(.geist(20, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + #endif + + // Panel-sized on the Mac, a touch smaller in hand, and read-from-the-couch on TV. + #if os(tvOS) + private static let iconSide: CGFloat = 160 + private static let nameFont: CGFloat = 44 + private static let versionFont: CGFloat = 24 + private static let taglineFont: CGFloat = 22 + #else + private static let iconSide: CGFloat = 96 + private static let nameFont: CGFloat = 28 + private static let versionFont: CGFloat = 14 + private static let taglineFont: CGFloat = 13 + #endif +} + +/// The app's own icon, at whatever size About asks for. +/// +/// Every platform hides it somewhere different, and one of them can't hand it over at all: macOS +/// has the running app's icon; iOS keeps the primary icon's file names in `CFBundleIcons`; tvOS +/// app icons are layered assets with no single image to load, and an unbundled `swift run` build +/// has no icon in the first place. So there is a drawn fallback, built from the same brand +/// gradient and Geist monogram as the host cards — where the real icon is unavailable this reads +/// as a mark, not as a missing image. +struct AppIconView: View { + let side: CGFloat + + var body: some View { + Group { + if let image = Self.bundleIcon { + image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + } else { + monogram + } + } + .frame(width: side, height: side) + .accessibilityHidden(true) // the app's name is the next line + } + + private var monogram: some View { + let shape = RoundedRectangle(cornerRadius: side * 0.22, style: .continuous) + return shape + .fill(LinearGradient( + colors: [Color.brand, Color.brand.opacity(0.7)], + startPoint: .top, endPoint: .bottom)) + .overlay { + Text("P") + .font(.geistFixed(side * 0.5, .bold)) + .foregroundStyle(.white) + } + } + + private static var bundleIcon: Image? { + #if os(macOS) + return Image(nsImage: NSApplication.shared.applicationIconImage) + #elseif os(iOS) + // The last entry is the largest — `CFBundleIconFiles` is ordered small to large. + guard let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any], + let primary = icons["CFBundlePrimaryIcon"] as? [String: Any], + let files = primary["CFBundleIconFiles"] as? [String], + let name = files.last, + let image = UIImage(named: name) + else { return nil } + return Image(uiImage: image) + #else + return nil // tvOS: layered icons have no single image to load + #endif + } +} + +/// `textSelection(.enabled)` doesn't exist on tvOS, and a version string is the one thing here +/// worth copying into a bug report. +private struct SelectableIfPossible: ViewModifier { + func body(content: Content) -> some View { + #if os(tvOS) + content + #else + content.textSelection(.enabled) + #endif + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index fcd023f0..cd69a567 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -316,16 +316,66 @@ extension SettingsView { nameDraft = Self.copyName(of: active.name, in: profiles) nameAction = .duplicate(active.id) } + colorMenu(active) Button("Delete “\(active.name)”…", role: .destructive) { profilePendingDelete = active } } } + /// "Color ▸" — what tints this profile's chip on the host cards and its dot here. A palette + /// rather than a colour well: the chip is small tinted text on a tinted capsule, and a colour + /// picked freehand lands somewhere unreadable often enough to matter (see `ProfileAccent`). + @ViewBuilder + private func colorMenu(_ active: StreamProfile) -> some View { + Menu("Color") { + Button { + profiles.setAccent(active.id, to: nil) + } label: { + Self.checkable("Default", on: active.accent == nil) + } + ForEach(ProfileAccent.palette) { accent in + Button { + profiles.setAccent(active.id, to: accent.hex) + } label: { + Self.checkable( + accent.name, + on: active.accent?.caseInsensitiveCompare(accent.hex) == .orderedSame) + } + } + } + } + + /// A menu row carrying a checkmark when it is the current choice. Two shapes rather than one + /// `Label` with an empty symbol name — `Image(systemName: "")` is not a blank image. + @ViewBuilder + private static func checkable(_ title: String, on: Bool) -> some View { + if on { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + /// The name of the layer being edited, and one line on what editing it means. Shared by both /// chromes — the caption is a stacked line on macOS and the list section's footer on iOS. var scopeName: String { activeProfile?.name ?? "Default settings" } + /// The edited layer as a glyph: a profile's own colour, or the settings gear for the + /// defaults. Colour is only worth choosing if it shows up where you chose it. + @ViewBuilder + var scopeDot: some View { + if let profile = activeProfile { + Circle() + .fill(profile.accentColor) + .frame(width: 9, height: 9) + .accessibilityHidden(true) // the name is right beside it + } else { + Image(systemName: "gearshape") + .foregroundStyle(.secondary) + } + } + var scopeCaption: String { activeProfile == nil ? "The settings every profile inherits from." @@ -339,9 +389,10 @@ extension SettingsView { profilePrompts( VStack(alignment: .leading, spacing: 4) { Menu { scopeMenuContent } label: { - Label( - scopeName, - systemImage: activeProfile == nil ? "gearshape" : "slider.horizontal.3") + HStack(spacing: 6) { + scopeDot + Text(scopeName) + } } .menuStyle(.button) .fixedSize() @@ -365,6 +416,7 @@ extension SettingsView { Text("Editing") .foregroundStyle(.primary) Spacer(minLength: 8) + scopeDot Text(scopeName) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index b19ef039..a6c4efc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -218,7 +218,7 @@ struct SettingsView: View { .tabItem { Label("Controllers", systemImage: "gamecontroller") } .tag(MacTab.controllers) - AcknowledgementsView() + AboutView() .tabItem { Label("About", systemImage: "info.circle") } .tag(MacTab.about) } @@ -346,10 +346,11 @@ struct SettingsView: View { .navigationTitle("Controllers") .navigationBarTitleDisplayMode(.inline) case .about: - // Already a full scrollable view that sets its own "Acknowledgements" title; pin the - // display mode inline to match the five sibling detail pages (it would otherwise inherit - // the large title from the "Settings" sidebar root). - AcknowledgementsView() + // The identity card; the license wall is one push further in. Inline title to match + // the five sibling detail pages (it would otherwise inherit the large title from the + // "Settings" sidebar root). + AboutView() + .navigationTitle("About") .navigationBarTitleDisplayMode(.inline) } } @@ -481,7 +482,7 @@ struct SettingsView: View { title: "Gamepad-optimized browsing", options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag) tvCaption(Self.controllersFooter) - NavigationLink("Acknowledgements") { AcknowledgementsView() } + NavigationLink("About") { AboutView() } .padding(.top, 8) } .frame(maxWidth: 1000) diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index 07318637..4797f10c 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -55,6 +55,9 @@ public struct EffectiveSettings: Equatable, Sendable { /// to be guessed from the settings themselves. public var profileID: String? public var profileName: String? + /// The profile's `#RRGGBB` chip colour, so the HUD names it in the same colour the card that + /// launched it wore — the colour is an identifier, and it only works if it's the same one. + public var profileAccent: String? public init() {} @@ -170,6 +173,7 @@ public struct EffectiveSettings: Equatable, Sendable { var out = base.applying(profile.overrides) out.profileID = profile.id out.profileName = profile.name + out.profileAccent = profile.accent return out } } diff --git a/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift b/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift new file mode 100644 index 00000000..f4875ba8 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift @@ -0,0 +1,60 @@ +// A profile's chip colour (design/client-settings-profiles.md §5.1 "Change color"). The catalog +// stores `accent` as `#RRGGBB` — a plain string, so the Rust and Kotlin catalogs read the same +// value — and this is the palette the Apple client offers for it. +// +// A fixed palette rather than a full colour well, for two reasons. The chip is small, tinted text +// on a tinted capsule, so a colour picked freehand can land somewhere unreadable; and a profile's +// colour is an IDENTIFIER — "the orange one" — which works when there are eight of them and stops +// working when there are sixteen million. Any `#RRGGBB` a newer client (or another platform) +// writes still renders: the palette is what this client OFFERS, not what it accepts. + +import SwiftUI + +public struct ProfileAccent: Identifiable, Sendable, Hashable { + /// `#RRGGBB`, lowercase — exactly what lands in the catalog. + public let hex: String + public let name: String + + public var id: String { hex } + + public var color: Color { Color(hex: hex) ?? .brand } + + /// Hues that stay legible as tinted text on their own tinted capsule, in both appearances, + /// and that stay tellable apart from each other at chip size. + public static let palette: [ProfileAccent] = [ + ProfileAccent(hex: "#8b5cf6", name: "Violet"), + ProfileAccent(hex: "#3aa0ff", name: "Blue"), + ProfileAccent(hex: "#2bb8a6", name: "Teal"), + ProfileAccent(hex: "#35c759", name: "Green"), + ProfileAccent(hex: "#e0a800", name: "Amber"), + ProfileAccent(hex: "#ff8800", name: "Orange"), + ProfileAccent(hex: "#ff4f6d", name: "Red"), + ProfileAccent(hex: "#ff4f9a", name: "Pink"), + ProfileAccent(hex: "#8e8e93", name: "Graphite"), + ] + + public static func named(_ hex: String?) -> ProfileAccent? { + guard let hex else { return nil } + return palette.first { $0.hex.caseInsensitiveCompare(hex) == .orderedSame } + } +} + +public extension Color { + /// Parse a `#RRGGBB` string. nil for anything else — a profile with an accent this build + /// can't read falls back to the brand tint rather than rendering as black. + init?(hex: String) { + guard hex.hasPrefix("#"), hex.count == 7, + let value = UInt32(hex.dropFirst(), radix: 16) + else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255) + } +} + +public extension StreamProfile { + /// This profile's colour, or the brand tint when it has none — every surface that shows a + /// profile reads it through here, so "no colour chosen" looks deliberate everywhere. + var accentColor: Color { Color(hex: accent ?? "") ?? .brand } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift index 4051a42d..0e84ef56 100644 --- a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -8,6 +8,7 @@ // • the settings-profile catalog — including the don't-clobber rule that keeps an older build // from erasing a newer one's overlay fields just by opening a profile. +import SwiftUI import XCTest @testable import PunktfunkKit @@ -354,6 +355,26 @@ final class SharedFoundationTests: XCTestCase { XCTAssertEqual(catalog.pinned(for: host).map(\.id), ["222222222222", "111111111111"]) } + /// The accent is a plain `#RRGGBB` string in the catalog — the palette is what this client + /// OFFERS, not what it accepts, so a colour another platform wrote still renders and a + /// malformed one falls back to the brand tint rather than to black. + func testProfileAccentParsing() { + XCTAssertNotNil(Color(hex: "#ff8800")) + XCTAssertNil(Color(hex: "ff8800"), "a missing # is not a colour") + XCTAssertNil(Color(hex: "#ff88"), "a short value is not a colour") + XCTAssertNil(Color(hex: "#gggggg"), "non-hex is not a colour") + XCTAssertNil(Color(hex: "")) + + // Every offered swatch parses, and each is distinguishable by name and value. + XCTAssertEqual(Set(ProfileAccent.palette.map(\.hex)).count, ProfileAccent.palette.count) + for accent in ProfileAccent.palette { + XCTAssertNotNil(Color(hex: accent.hex), accent.name) + XCTAssertEqual(ProfileAccent.named(accent.hex.uppercased())?.name, accent.name) + } + XCTAssertNil(ProfileAccent.named(nil)) + XCTAssertNil(ProfileAccent.named("#123456"), "an unlisted colour has no palette name") + } + /// The whole per-connect resolution, in the precedence every client shares: /// one-off pick ?? host binding ?? none. func testEffectiveSettingsResolutionPrecedence() { @@ -367,7 +388,8 @@ final class SharedFoundationTests: XCTestCase { var workOverrides = SettingsOverlay() workOverrides.bitrateKbps = 8_000 let catalog = ProfileCatalog(profiles: [ - StreamProfile(name: "Game", id: "111111111111", overrides: gameOverrides), + StreamProfile( + name: "Game", id: "111111111111", accent: "#ff8800", overrides: gameOverrides), StreamProfile(name: "Work", id: "222222222222", overrides: workOverrides), ]) var host = StoredHost(name: "Desk", address: "10.0.0.1") @@ -383,6 +405,9 @@ final class SharedFoundationTests: XCTestCase { let bound = EffectiveSettings.resolve(host: host, catalog: catalog, defaults: defaults) XCTAssertEqual(bound.bitrateKbps, 80_000) XCTAssertEqual(bound.profileName, "Game") + // The chip colour rides along, so the HUD can name the session in the same colour the + // card that launched it wore. + XCTAssertEqual(bound.profileAccent, "#ff8800") // A one-off pick wins over the binding — and does not rebind anything. let oneOff = EffectiveSettings.resolve(