Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/AboutView.swift
T
enricobuehlerandClaude Opus 5 556ece4bc0 fix(client/apple): don't call a paid app "free software"
The term means liberty, but it is read as price — and this app is sold on the App
Store, so the person most likely to read that line is the one who just paid for it.
The licences are unchanged and the claim was true in its own sense; it was the wrong
sense to leave ambiguous on a purchase.

It now says what is unambiguously so: the SOURCE is open under MIT or Apache-2.0.
Both copies (the form footer and the tvOS page), with a note on the wording so it
doesn't drift back.

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

286 lines
12 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 opens it as a sheet — pushed
// only on tvOS, where this page really is inside a navigation stack. The iPad settings detail
// column deliberately isn't one, so a push there had no way back.
//
// Wording note: this page says the SOURCE is open, never that the app is "free software". The
// term means liberty, but it is read as price — and this app is sold on the App Store, so to the
// person most likely to read this line it would be a claim about what they just paid for.
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(tvOS)
@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's source is open under MIT or Apache-2.0.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
// A SHEET, not a push — on iPad the settings detail column is deliberately not a
// NavigationStack (an inner one doubles the title bar), so a NavigationLink from here
// pushed into a context with no back button and stranded the licenses on screen.
.sheet(isPresented: $showAcknowledgements) {
NavigationStack {
AcknowledgementsView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showAcknowledgements = false }
}
}
}
#if os(macOS)
.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)
}
private var acknowledgementsRow: some View {
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)
}
#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's source is open 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 icon = Self.bundleIcon {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at
// draw time, so used raw it is a hard-cornered square. macOS bakes its own
// shape (and margins) into the image, and clipping that would cut into it.
.clipShape(RoundedRectangle(
cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0,
style: .continuous))
} else {
monogram
}
}
.frame(width: side, height: side)
.accessibilityHidden(true) // the app's name is the next line
}
/// The iOS icon's corner radius as a fraction of its side — the squircle's ~22.37%.
private static let iOSCornerRatio: CGFloat = 0.2237
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)
}
}
/// `needsMask` = the platform hands over a bare square that the OS normally rounds for us.
private static var bundleIcon: (image: Image, needsMask: Bool)? {
#if os(macOS)
return (Image(nsImage: NSApplication.shared.applicationIconImage), false)
#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), true)
#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
}
}