Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift
T
enricobuehlerandClaude Opus 5 f50d13752d feat(client/apple): an About page worth opening, and profile colours you can actually pick
About was a bare "Punktfunk / Version x" header over 885 KB of license text — it
answered the one question nobody opens About to ask. It is now the shape every Mac
user already knows: the app's icon, its name, the version (selectable, because a bug
report is worth more with it), the tagline, and then the ways out — documentation,
community, source, licenses. The license wall is still `AcknowledgementsView`, one
push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation
stack to push onto.

Every platform hides the app icon somewhere different and tvOS can't hand it over at
all (layered assets, no single image), so there's a drawn fallback in the same brand
gradient and Geist monogram as the host cards — where the real icon is unavailable it
reads as a mark, not as a broken image. tvOS also has no browser: the addresses are
text to read off the screen rather than links to nowhere.

And profile colours were half-built by me: the catalog stored `accent`, the chips
tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸"
over a fixed palette — the chip is small tinted text on a tinted capsule, and a
colour picked freehand lands somewhere unreadable often enough to matter. The palette
is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform
writes still renders, which is what Android needs from this field.

The colour now shows wherever the profile is named — the scope switcher's dot, the
card chips, and the HUD's session line, which tints to the same colour the card that
launched it wore. A colour is an identifier, and an identifier that changes between
surfaces isn't one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

277 lines
10 KiB
Swift

// 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
}
}