From 86979d0abc8703426e86ef4cfaa15c9ba819136f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 19 Jun 2026 15:49:48 +0200 Subject: [PATCH] fix build improve iOS & iPadOS UI --- .vscode/launch.json | 24 +++++++ .../apple/Punktfunk.xcodeproj/project.pbxproj | 4 ++ .../PunktfunkClient/AddHostSheet.swift | 46 ++++++++++--- .../Sources/PunktfunkClient/ContentView.swift | 8 ++- .../Sources/PunktfunkClient/GlassStyle.swift | 69 +++++++++++++++++++ .../Sources/PunktfunkClient/HomeView.swift | 2 +- .../Sources/PunktfunkClient/HostCards.swift | 2 + .../Sources/PunktfunkClient/LibraryView.swift | 2 +- .../Sources/PunktfunkClient/PairSheet.swift | 11 ++- .../PunktfunkClient/SpeedTestSheet.swift | 10 ++- .../PunktfunkClient/StreamHUDView.swift | 4 +- .../PunktfunkClient/TrustCardView.swift | 8 ++- scripts/build-xcframework.sh | 21 +++++- 13 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 clients/apple/Sources/PunktfunkClient/GlassStyle.swift diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d281510 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:punktfunk}/clients/apple", + "name": "Debug PunktfunkClient (clients/apple)", + "target": "PunktfunkClient", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug PunktfunkClient (clients/apple)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:punktfunk}/clients/apple", + "name": "Release PunktfunkClient (clients/apple)", + "target": "PunktfunkClient", + "configuration": "release", + "preLaunchTask": "swift: Build Release PunktfunkClient (clients/apple)" + } + ] +} \ No newline at end of file diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index a110a62..39a3386 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -425,6 +425,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; @@ -463,6 +464,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; @@ -500,6 +502,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; @@ -529,6 +532,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index c8619bb..9326597 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -81,24 +81,50 @@ struct AddHostSheet: View { #if !os(tvOS) .formStyle(.grouped) #endif + #if os(macOS) + // macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the + // window's default/cancel keyboard actions. The 380-wide .fixedSize panel below + // keeps this compact and centered. HStack { Button("Cancel", role: .cancel) { dismiss() } - #if !os(tvOS) .keyboardShortcut(.cancelAction) - #endif Spacer() Button("Add Host") { add() } - .buttonStyle(.borderedProminent) - #if !os(tvOS) - .keyboardShortcut(.defaultAction) - #endif - .disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) + .glassProminentButtonStyle() + .keyboardShortcut(.defaultAction) + .disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) } - #if os(iOS) - .controlSize(.large) - #endif .padding(16) + #else + // iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator, + // swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled, + // so all three are live; if anyone adds it later, restore a Cancel here or there is + // no way back out.) A single FULL-WIDTH primary action reads as the one thing to do. + // The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the + // Button only widens its hit area and leaves the styled capsule hugging the text — + // stretching the label is what makes the glass/bordered pill itself go edge-to-edge. + // .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a + // hardware keyboard / iPad Return submit. + Button { add() } label: { + Text("Add Host").frame(maxWidth: .infinity) + } + .glassProminentButtonStyle() + .controlSize(.large) + .keyboardShortcut(.defaultAction) + .disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(16) + #endif } + #if os(iOS) + // A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped + // Form + the full-width action row, instead of the half-screen .medium it used to rest + // at. A single fixed detent is enough: the system keeps the content above the keyboard + // when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a + // centered formSheet card). If Dynamic Type grows the rows past this height the Form just + // scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.) + .presentationDetents([.height(320)]) + .presentationDragIndicator(.visible) + #endif #if os(macOS) .frame(width: 380) .fixedSize(horizontal: false, vertical: true) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index d428059..2ac8cb4 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -205,7 +205,13 @@ struct ContentView: View { Image(systemName: "xmark") .font(.headline.weight(.semibold)) .frame(width: 36, height: 36) - .background(.regularMaterial, in: Circle()) + // Sole touch exit when the HUD is off — a floating glass disc + // over the frame (26+, material fallback). interactive: the disc + // IS the tap target, so the glass reacts to press. + .glassBackground(Circle(), interactive: true) + // Match the hit region to the visible disc so every tap also + // triggers the interactive-glass press highlight. + .contentShape(Circle()) } .buttonStyle(.plain) .padding(12) diff --git a/clients/apple/Sources/PunktfunkClient/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/GlassStyle.swift new file mode 100644 index 0000000..2f3f467 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/GlassStyle.swift @@ -0,0 +1,69 @@ +// GlassStyle.swift — the app's single, availability-gated entry point to Apple's "Liquid +// Glass" (iOS / macOS / tvOS 26). Every Liquid Glass symbol (glassEffect, Glass, the +// .glassProminent button style …) is HARD-gated to OS 26: referencing one with our +// deployment targets (macOS 14 / iOS 17 / tvOS 17) is a COMPILE error, not a silent no-op, +// unless it sits behind `if #available`. So all glass in the app routes through the two +// helpers below, each of which falls back to the EXACT look the app shipped before +// (.regularMaterial / .borderedProminent) — nothing regresses on older OSes, and the gating +// lives in exactly one file. + +import SwiftUI + +// MARK: - Glass background + +/// Liquid Glass behind a floating / overlay surface, with the pre-26 `.regularMaterial` +/// look as the fallback. Use ONLY on the floating control / overlay layer (the streaming +/// HUD, the trust card, the touch exit chip) — never on content tiles or dense forms (HIG). +/// +/// `glassEffect()`'s own default shape is a Capsule, so panels MUST pass an explicit shape +/// (a RoundedRectangle / Circle) or they render as a pill. `interactive` makes the glass +/// react to press — only meaningful when the glass itself is the tap target. +private struct GlassBackground: ViewModifier { + let shape: S + var interactive = false + + func body(content: Content) -> some View { + if #available(iOS 26, macOS 26, tvOS 26, *) { + content.glassEffect(interactive ? .regular.interactive() : .regular, in: shape) + } else { + content.background(.regularMaterial, in: shape) + } + } +} + +extension View { + /// Liquid Glass (26+) or the existing `.regularMaterial` (pre-26) behind a floating + /// surface. Pass the surface's shape explicitly — glass defaults to a Capsule otherwise. + func glassBackground(_ shape: S, interactive: Bool = false) -> some View { + modifier(GlassBackground(shape: shape, interactive: interactive)) + } +} + +// MARK: - Glass primary button + +/// The single prominent action on a floating / overlay or sheet surface: the Liquid-Glass +/// prominent button style on 26+, falling back to `.borderedProminent` (the app's current +/// primary style) below. Apply directly to a `Button`; role / keyboardShortcut / disabled +/// chain after it as usual. tvOS stays `.borderedProminent` always — glass chrome fights the +/// focus engine, and keeping it preserves today's tvOS look exactly. +private struct GlassProminentButton: ViewModifier { + func body(content: Content) -> some View { + #if os(tvOS) + content.buttonStyle(.borderedProminent) + #else + if #available(iOS 26, macOS 26, *) { + content.buttonStyle(.glassProminent) + } else { + content.buttonStyle(.borderedProminent) + } + #endif + } +} + +extension View { + /// Liquid-Glass prominent style (26+, non-tvOS) or `.borderedProminent`. Drop-in for the + /// `.buttonStyle(.borderedProminent)` on a surface's primary action. + func glassProminentButtonStyle() -> some View { + modifier(GlassProminentButton()) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/HomeView.swift b/clients/apple/Sources/PunktfunkClient/HomeView.swift index b56c904..aee5374 100644 --- a/clients/apple/Sources/PunktfunkClient/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/HomeView.swift @@ -217,7 +217,7 @@ struct HomeView: View { Text("Add your punktfunk host with the + button.") } actions: { Button("Add Host") { showAddHost = true } - .buttonStyle(.borderedProminent) + .glassProminentButtonStyle() #if os(iOS) .controlSize(.large) #endif diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/HostCards.swift index 8b3ba71..9ad1641 100644 --- a/clients/apple/Sources/PunktfunkClient/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/HostCards.swift @@ -88,6 +88,8 @@ struct HostCardView: View { #if !os(tvOS) // tvOS: the .card button style owns platter + focus motion — extra chrome // inside it mutes the grow/tilt. Material + accent ring are for pointer UIs. + // Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content + // tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift. .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay { if isMostRecent { diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/LibraryView.swift index 753c98f..edf2b34 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/LibraryView.swift @@ -81,7 +81,7 @@ struct LibraryView: View { .foregroundStyle(.secondary) .frame(maxWidth: 420) Button("Retry") { Task { await load() } } - .buttonStyle(.borderedProminent) + .glassProminentButtonStyle() } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index e180f75..e1f5755 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -150,7 +150,7 @@ struct PairSheet: View { .padding(.trailing, 8) } Button("Pair & Connect") { runCeremony() } - .buttonStyle(.borderedProminent) + .glassProminentButtonStyle() #if !os(tvOS) .keyboardShortcut(.defaultAction) #endif @@ -165,6 +165,15 @@ struct PairSheet: View { .frame(width: 400) .fixedSize(horizontal: false, vertical: true) #endif + #if os(iOS) + // Bottom sheet instead of a full-screen modal (Liquid Glass background on iOS 26). + // .medium rests; .large is included so the sheet grows to keep the Pair/Cancel row + // above the keyboard when the PIN field is focused. Hide the grabber while the ceremony + // is in flight — dismissal is disabled then (interactiveDismissDisabled), so a drag + // would only rubber-band; the always-enabled Cancel button is the exit. + .presentationDetents([.medium, .large]) + .presentationDragIndicator(busy ? .hidden : .visible) + #endif .interactiveDismissDisabled(busy) .onDisappear { token.cancelled = true } // any other dismissal path #endif diff --git a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift index 660502c..3fa2770 100644 --- a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift @@ -91,14 +91,14 @@ struct SpeedTestSheet: View { bitrateKbps = rec dismiss() } - .buttonStyle(.borderedProminent) + .glassProminentButtonStyle() #if !os(tvOS) .keyboardShortcut(.defaultAction) #endif } if case .failed = phase { Button("Retry") { run() } - .buttonStyle(.borderedProminent) + .glassProminentButtonStyle() } } } @@ -112,6 +112,12 @@ struct SpeedTestSheet: View { .frame(width: 420) .fixedSize(horizontal: false, vertical: true) #endif + #if os(iOS) + // Bottom sheet rather than a full-screen modal; .medium stays put as the result view + // swaps in (a measured height would resize the sheet mid-probe). + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + #endif .onAppear { run() } .onDisappear { token.cancelled = true } } diff --git a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift index 6a8dcca..f59cd18 100644 --- a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift @@ -99,7 +99,9 @@ struct StreamHUDView: View { #endif } .padding(10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + // Floating HUD over live video — the canonical Liquid-Glass overlay surface (26+); + // falls back to .regularMaterial below 26 (see GlassStyle). + .glassBackground(RoundedRectangle(cornerRadius: 10)) .padding(10) } } diff --git a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift b/clients/apple/Sources/PunktfunkClient/TrustCardView.swift index 0adcba2..acb0dab 100644 --- a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift +++ b/clients/apple/Sources/PunktfunkClient/TrustCardView.swift @@ -38,6 +38,10 @@ struct TrustCardView: View { .keyboardShortcut(.cancelAction) #endif Button("Trust & Connect", action: onTrust) + // Opaque prominent, NOT glass: this card is itself a glass panel + // (.glassBackground below), and glass-on-glass loses contrast — a tinted + // bordered button reads cleanly over glass (HIG). The sheet primaries stay + // glass because the system manages the sheet's own glass layering. .buttonStyle(.borderedProminent) #if !os(tvOS) .keyboardShortcut(.defaultAction) @@ -58,7 +62,9 @@ struct TrustCardView: View { } .padding(28) .frame(maxWidth: 440) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) + // Floating trust card over the blurred stream — Liquid Glass on 26+, .regularMaterial + // fallback below. The inner fingerprint box stays .quaternary (content, not glass). + .glassBackground(RoundedRectangle(cornerRadius: 18)) } /// 64 hex chars → four groups per line, two lines — easy to eyeball against the log. diff --git a/scripts/build-xcframework.sh b/scripts/build-xcframework.sh index 664a054..eb7bbed 100644 --- a/scripts/build-xcframework.sh +++ b/scripts/build-xcframework.sh @@ -145,4 +145,23 @@ fi rm -rf clients/apple/PunktfunkCore.xcframework "${XCODEBUILD[@]}" -create-xcframework "${ARGS[@]}" -output clients/apple/PunktfunkCore.xcframework -echo "OK: clients/apple/PunktfunkCore.xcframework" + +# Xcode (unlike `swift build`) refuses to EMBED an unsigned xcframework: the app targets in +# Punktfunk.xcodeproj fail with "The framework 'PunktfunkCore.xcframework' is unsigned". So +# sign the bundle here. Identity: $CODESIGN_IDENTITY if set, else the first "Apple Development" +# identity in the keychain, else ad-hoc ("-") — ad-hoc satisfies `swift build` and most local +# Xcode runs; a real identity is needed for device/distribution. --timestamp=none keeps it +# offline (a secure timestamp only matters for notarized distribution, which re-signs anyway). +SIGN_ID="${CODESIGN_IDENTITY:-}" +if [[ -z "$SIGN_ID" ]]; then + SIGN_ID=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Apple Development/ {print $2; exit}') +fi +SIGN_ID="${SIGN_ID:--}" # ad-hoc fallback when no real identity is available +if codesign --force --timestamp=none --sign "$SIGN_ID" clients/apple/PunktfunkCore.xcframework; then + echo "OK: clients/apple/PunktfunkCore.xcframework (signed: $SIGN_ID)" +else + echo "WARN: clients/apple/PunktfunkCore.xcframework built but NOT signed — Xcode app" >&2 + echo " builds will report it unsigned. Set CODESIGN_IDENTITY and re-run." >&2 + echo "OK: clients/apple/PunktfunkCore.xcframework (unsigned)" +fi