The iPad settings detail column is deliberately NOT a NavigationStack — an inner one doubles the title bar, which the code says right where it sets it up. I put a NavigationLink in it anyway, so opening Acknowledgements pushed into a context with no back button and left the license wall with no way out. It is a sheet on iOS now, the same one macOS already used; only tvOS still pushes, where the page really is inside a stack. And iOS hands over the app icon UNMASKED: the springboard applies the rounded shape at draw time, so used raw it is a hard-cornered square — the filled corners. It gets the squircle radius applied here. macOS is left alone: it bakes its own shape and margins into the image, and clipping that would cut into the icon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
11 KiB
Swift
282 lines
11 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.
|
|
|
|
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 is free software 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 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 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
|
|
}
|
|
}
|