feat(apple): wake-until-up overlay + host edit with MAC prefill
apple / swift (push) Successful in 1m10s
arch / build-publish (push) Successful in 5m17s
android / android (push) Successful in 7m30s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m11s
release / apple (push) Successful in 8m39s
ci / rust (push) Successful in 4m53s
deb / build-publish (push) Successful in 2m58s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m50s
apple / screenshots (push) Successful in 5m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m52s
apple / swift (push) Successful in 1m10s
arch / build-publish (push) Successful in 5m17s
android / android (push) Successful in 7m30s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m11s
release / apple (push) Successful in 8m39s
ci / rust (push) Successful in 4m53s
deb / build-publish (push) Successful in 2m58s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m50s
apple / screenshots (push) Successful in 5m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m52s
- HostWaker + WakeOverlay: after sending the Wake-on-LAN packet, wait until the host is really back (resend + mDNS poll, timeout, cancel/retry) before connecting. macOS-only in practice — WoL stays gated off on iOS/tvOS pending the multicast entitlement. - Add/Edit host sheet gains a Wake-on-LAN MAC field, prefilled from the stored MAC or the live mDNS advert; parseMacs validates aa:bb:cc:dd:ee:ff. - Gamepad chrome/home and glass-style polish. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,9 @@ struct ContentView: View {
|
|||||||
@State private var awaitingApproval: ApprovalRequest?
|
@State private var awaitingApproval: ApprovalRequest?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
|
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||||
|
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||||
|
@StateObject private var waker = HostWaker()
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -212,12 +215,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
|
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
||||||
|
// `home`, so it covers the whole wake→online→connect sequence).
|
||||||
|
homeBase.overlay { WakeOverlay(waker: waker) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var homeBase: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Group {
|
Group {
|
||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget,
|
libraryTarget: $libraryTarget, waker: waker,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
} else {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
@@ -225,7 +234,7 @@ struct ContentView: View {
|
|||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
@@ -233,7 +242,7 @@ struct ContentView: View {
|
|||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget,
|
libraryTarget: $libraryTarget, waker: waker,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
} else {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
@@ -242,7 +251,7 @@ struct ContentView: View {
|
|||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
showSettings: $showSettings,
|
showSettings: $showSettings,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
@@ -252,7 +261,7 @@ struct ContentView: View {
|
|||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
showSettings: $showSettings,
|
showSettings: $showSettings,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,9 +415,37 @@ struct ContentView: View {
|
|||||||
/// delegated-approval connect (host parks it until the operator approves).
|
/// delegated-approval connect (host parks it until the operator approves).
|
||||||
private func startSession(
|
private func startSession(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||||
|
) {
|
||||||
|
let go = {
|
||||||
|
startSessionDirect(
|
||||||
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||||
|
}
|
||||||
|
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
||||||
|
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
||||||
|
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
||||||
|
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
|
discovery.start() // so we can observe it reappear
|
||||||
|
waker.start(
|
||||||
|
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||||
|
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||||
|
} else {
|
||||||
|
go()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||||||
|
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||||
|
/// is advertising (and is a harmless no-op otherwise).
|
||||||
|
private func startSessionDirect(
|
||||||
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||||
) {
|
) {
|
||||||
prepareWake(for: host)
|
prepareWake(for: host)
|
||||||
|
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||||
|
// here (after any wake), not before, so it never stacks under the "Waking…" overlay.
|
||||||
|
if let approvalReq { awaitingApproval = approvalReq }
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -452,12 +489,24 @@ struct ContentView: View {
|
|||||||
/// as paired (see the `.streaming` branch of `onChange`).
|
/// as paired (see the `.streaming` branch of `onChange`).
|
||||||
private func requestAccess(_ req: ApprovalRequest) {
|
private func requestAccess(_ req: ApprovalRequest) {
|
||||||
guard !model.isBusy else { return }
|
guard !model.isBusy else { return }
|
||||||
awaitingApproval = req
|
|
||||||
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||||
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
var host = req.host
|
var host = req.host
|
||||||
host.pinnedSHA256 = req.advertisedFingerprint
|
host.pinnedSHA256 = req.advertisedFingerprint
|
||||||
startSession(host, allowTofu: false, requestAccess: true)
|
// `awaitingApproval` is set inside startSessionDirect (after any wake), so it never stacks
|
||||||
|
// under the "Waking…" overlay.
|
||||||
|
startSession(host, allowTofu: false, requestAccess: true, approvalReq: req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit wake-only (the touch card's "Wake Host" menu item / a future gamepad action): fire
|
||||||
|
/// the packet and wait for the host to come online, but don't connect — the user then sees it
|
||||||
|
/// go online and can connect.
|
||||||
|
private func wakeOnly(_ host: StoredHost) {
|
||||||
|
guard PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty else { return }
|
||||||
|
discovery.start()
|
||||||
|
waker.start(
|
||||||
|
host: host, connectsAfter: false, macs: host.wakeMacs, lastIP: host.address,
|
||||||
|
isOnline: { discovery.advertises(host) }, onOnline: {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||||
|
|||||||
@@ -1,67 +1,87 @@
|
|||||||
// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first
|
// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC → a card in the grid.
|
||||||
// actual connection runs the trust-on-first-use fingerprint prompt.
|
// The MAC prefills from what we already know — the host's stored MAC, or the live mDNS advert's if
|
||||||
|
// it hasn't been learned yet — so it's usually already correct; type/paste it for a host we've
|
||||||
|
// never seen advertise. The first actual connection still runs the trust-on-first-use prompt.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AddHostSheet: View {
|
struct AddHostSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var name = ""
|
|
||||||
@State private var address = ""
|
/// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
|
||||||
@State private var port = 9777
|
let existing: StoredHost?
|
||||||
|
/// MAC(s) to offer when the host has none stored yet — the live advert's, so the field is
|
||||||
|
/// prefilled the moment the host is on the network, even before a connect has learned it.
|
||||||
|
let suggestedMacs: [String]
|
||||||
|
let onSave: (StoredHost) -> Void
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var address: String
|
||||||
|
@State private var port: Int
|
||||||
|
@State private var mac: String
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private enum EditField: String, Identifiable {
|
private enum EditField: String, Identifiable {
|
||||||
case name, address, port
|
case name, address, port, mac
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
@State private var editing: EditField?
|
@State private var editingField: EditField?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let onAdd: (StoredHost) -> Void
|
private var isEditing: Bool { existing != nil }
|
||||||
|
private var actionTitle: String { isEditing ? "Save" : "Add Host" }
|
||||||
|
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
init(existing: StoredHost? = nil, suggestedMacs: [String] = [], onSave: @escaping (StoredHost) -> Void) {
|
||||||
|
self.existing = existing
|
||||||
|
self.suggestedMacs = suggestedMacs
|
||||||
|
self.onSave = onSave
|
||||||
|
_name = State(initialValue: existing?.name ?? "")
|
||||||
|
_address = State(initialValue: existing?.address ?? "")
|
||||||
|
_port = State(initialValue: Int(existing?.port ?? 9777))
|
||||||
|
let stored = existing?.macAddresses ?? []
|
||||||
|
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
||||||
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
TVFieldRow(
|
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
|
||||||
label: "Name", value: name, placeholder: "Optional"
|
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
|
||||||
) { editing = .name }
|
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
|
||||||
TVFieldRow(
|
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
|
||||||
label: "Address", value: address, placeholder: "IP or hostname"
|
|
||||||
) { editing = .address }
|
|
||||||
TVFieldRow(
|
|
||||||
label: "Port", value: String(port), placeholder: ""
|
|
||||||
) { editing = .port }
|
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
Button("Add Host") { add() }
|
Button(actionTitle) { save() }.disabled(!canSave)
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 1000)
|
.frame(maxWidth: 1000)
|
||||||
.padding(60)
|
.padding(60)
|
||||||
.navigationTitle("Add Host")
|
.navigationTitle(isEditing ? "Edit Host" : "Add Host")
|
||||||
.fullScreenCover(item: $editing) { field in
|
.fullScreenCover(item: $editingField) { field in
|
||||||
switch field {
|
switch field {
|
||||||
case .name:
|
case .name:
|
||||||
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
||||||
name = $0
|
name = $0
|
||||||
editing = nil
|
editingField = nil
|
||||||
}
|
}
|
||||||
case .address:
|
case .address:
|
||||||
TVTextEntry(title: "IP or hostname", text: address) {
|
TVTextEntry(title: "IP or hostname", text: address) {
|
||||||
address = $0.trimmingCharacters(in: .whitespaces)
|
address = $0.trimmingCharacters(in: .whitespaces)
|
||||||
editing = nil
|
editingField = nil
|
||||||
}
|
}
|
||||||
case .port:
|
case .port:
|
||||||
TVTextEntry(
|
TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
|
||||||
title: "Port", text: String(port), keyboardType: .numberPad
|
if let value = Int($0), (1...65535).contains(value) { port = value }
|
||||||
) {
|
editingField = nil
|
||||||
if let value = Int($0), (1...65535).contains(value) {
|
}
|
||||||
port = value
|
case .mac:
|
||||||
}
|
TVTextEntry(title: "MAC address(es), comma-separated — aa:bb:cc:dd:ee:ff", text: mac) {
|
||||||
editing = nil
|
mac = $0.trimmingCharacters(in: .whitespaces)
|
||||||
|
editingField = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,77 +91,77 @@ struct AddHostSheet: View {
|
|||||||
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
||||||
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
||||||
TextField("Port", value: $port, format: .number.grouping(.never))
|
TextField("Port", value: $port, format: .number.grouping(.never))
|
||||||
#if os(tvOS)
|
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
|
||||||
// tvOS floats the label above a non-empty field INSIDE the pill,
|
.autocorrectionDisabled()
|
||||||
// shoving the value off-center — the field is always prefilled
|
#if os(iOS)
|
||||||
// here, so drop the label there.
|
.textInputAutocapitalization(.never)
|
||||||
.labelsHidden()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
#endif
|
// The grouped form's default system text is oversized next to the app's Geist
|
||||||
|
// typography — bring it down and on-brand so the panel doesn't read out of place.
|
||||||
|
.font(.geist(12, relativeTo: .callout))
|
||||||
|
.controlSize(.small)
|
||||||
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
|
||||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#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 {
|
HStack {
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Add Host") { add() }
|
Button(actionTitle) { save() }
|
||||||
.glassProminentButtonStyle()
|
.glassProminentButtonStyle()
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(!canSave)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
#else
|
#else
|
||||||
// iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator,
|
Button { save() } label: {
|
||||||
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
|
Text(actionTitle).frame(maxWidth: .infinity)
|
||||||
// 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()
|
.glassProminentButtonStyle()
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(!canSave)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
|
// Four fields + the action row — a touch taller than the 3-field add sheet used to be.
|
||||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
.presentationDetents([.height(392)])
|
||||||
// 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). The Form itself is .scrollDisabled (above) so it can't
|
|
||||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
|
||||||
.presentationDetents([.height(320)])
|
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(width: 380)
|
.frame(width: 400)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func add() {
|
private func save() {
|
||||||
onAdd(StoredHost(
|
var host = existing ?? StoredHost(name: "", address: "")
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
host.name = name.trimmingCharacters(in: .whitespaces)
|
||||||
address: address.trimmingCharacters(in: .whitespaces),
|
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||||
port: UInt16(clamping: port)))
|
host.port = UInt16(clamping: port)
|
||||||
|
host.macAddresses = Self.parseMacs(mac)
|
||||||
|
onSave(host)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split comma/space/newline-separated MACs, keep only well-formed `aa:bb:cc:dd:ee:ff` (six hex
|
||||||
|
/// octets, normalized lower-case); nil when none are valid, so clearing the field clears the
|
||||||
|
/// stored MAC.
|
||||||
|
static func parseMacs(_ s: String) -> [String]? {
|
||||||
|
let macs = s
|
||||||
|
.split(whereSeparator: { ",; \n\t".contains($0) })
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||||
|
.filter { m in
|
||||||
|
let parts = m.split(separator: ":")
|
||||||
|
return parts.count == 6 && parts.allSatisfy { $0.count == 2 && UInt8($0, radix: 16) != nil }
|
||||||
|
}
|
||||||
|
return macs.isEmpty ? nil : macs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,16 +58,19 @@ struct GamepadAddHostView: View {
|
|||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, compact ? 4 : 8)
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
|
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
.background { GamepadTrayScrim(edge: .top) }
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
bottomTray
|
bottomTray
|
||||||
.padding(.horizontal, 22)
|
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||||
.padding(.vertical, compact ? 6 : 10)
|
.padding(.horizontal, compact ? 12 : 18)
|
||||||
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
|
.padding(.top, compact ? 6 : 10)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
.background { GamepadTrayScrim(edge: .bottom) }
|
||||||
}
|
}
|
||||||
.background { GamepadScreenBackground() }
|
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||||
|
.background { GamepadFormBackground() }
|
||||||
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
||||||
.onChange(of: port) { _, value in
|
.onChange(of: port) { _, value in
|
||||||
if value.count > 5 { port = String(value.prefix(5)) }
|
if value.count > 5 { port = String(value.prefix(5)) }
|
||||||
@@ -165,14 +168,16 @@ struct GamepadAddHostView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, 13)
|
||||||
.background {
|
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||||
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
|
.consoleGlass(
|
||||||
}
|
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||||
|
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||||
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0),
|
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||||
lineWidth: 1)
|
lineWidth: 1)
|
||||||
}
|
}
|
||||||
.scaleEffect(focused ? 1.0 : 0.98)
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ struct GamepadHint: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
||||||
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
|
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration —
|
||||||
|
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
|
||||||
|
/// the backdrop instead of dissolving into it.
|
||||||
struct GamepadHintBar: View {
|
struct GamepadHintBar: View {
|
||||||
let hints: [GamepadHint]
|
let hints: [GamepadHint]
|
||||||
|
|
||||||
@@ -57,39 +59,141 @@ struct GamepadHintBar: View {
|
|||||||
}
|
}
|
||||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
.padding(13)
|
||||||
|
.consoleGlass(Capsule())
|
||||||
|
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs
|
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||||||
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
|
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||||||
/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it
|
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||||||
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
|
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||||||
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
|
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||||
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
|
/// radial-blob field, unchanged, so nothing regresses.
|
||||||
/// two — radial gradients driven by a TimelineView give the same look with none of that risk.
|
|
||||||
///
|
///
|
||||||
/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here
|
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||||
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
|
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||||
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
|
/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that
|
||||||
|
/// risk. Applied via `.background { }` — NOT a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||||
|
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||||
|
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||||
struct GamepadScreenBackground: View {
|
struct GamepadScreenBackground: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
|
var body: some View {
|
||||||
/// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
Group {
|
||||||
|
if reduceMotion {
|
||||||
|
composite(at: 0)
|
||||||
|
} else {
|
||||||
|
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||||||
|
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||||
|
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||||||
|
/// title/hints legibility scrim.
|
||||||
|
private func composite(at t: TimeInterval) -> some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
colorField(at: t)
|
||||||
|
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||||
|
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||||
|
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||||||
|
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||||||
|
EllipticalGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.42)],
|
||||||
|
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||||
|
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||||
|
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||||||
|
// blur), so it stays a gradient, just a light one now.
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black.opacity(0.38), location: 0),
|
||||||
|
.init(color: .black.opacity(0.06), location: 0.32),
|
||||||
|
.init(color: .black.opacity(0.08), location: 0.68),
|
||||||
|
.init(color: .black.opacity(0.40), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||||||
|
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||||||
|
MeshGradient(
|
||||||
|
width: 4, height: 4,
|
||||||
|
points: Self.meshPoints(at: t),
|
||||||
|
colors: Self.meshColors,
|
||||||
|
smoothsColors: true)
|
||||||
|
} else {
|
||||||
|
LegacyBlobField(t: t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||||||
|
|
||||||
|
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||||||
|
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||||||
|
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||||||
|
/// the right, and the silk shifts temperature as those interior points drift.
|
||||||
|
private static let meshColors: [Color] = {
|
||||||
|
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||||||
|
return [
|
||||||
|
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||||||
|
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||||||
|
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||||||
|
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||||
|
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||||
|
/// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
|
||||||
|
/// (periods ~90–130 s) so the bright colour pools breathe without ever looking like they loop.
|
||||||
|
private static func meshPoints(at t: TimeInterval) -> [SIMD2<Float>] {
|
||||||
|
func wob(_ bx: Float, _ by: Float, _ a: Float,
|
||||||
|
_ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2<Float> {
|
||||||
|
SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3)))
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0),
|
||||||
|
SIMD2(0, 0.333),
|
||||||
|
wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||||||
|
wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||||||
|
SIMD2(1, 0.333),
|
||||||
|
SIMD2(0, 0.667),
|
||||||
|
wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||||||
|
wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||||
|
SIMD2(1, 0.667),
|
||||||
|
SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||||||
|
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||||||
|
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||||||
|
private struct LegacyBlobField: View {
|
||||||
|
let t: TimeInterval
|
||||||
|
|
||||||
|
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||||||
|
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||||
private struct Blob {
|
private struct Blob {
|
||||||
let color: Color
|
let color: Color
|
||||||
let center: CGPoint
|
let center: CGPoint
|
||||||
let drift: CGSize
|
let drift: CGSize
|
||||||
let speed: (x: Double, y: Double)
|
let speed: (x: Double, y: Double)
|
||||||
let phase: (x: Double, y: Double)
|
let phase: (x: Double, y: Double)
|
||||||
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
|
|
||||||
let radius: CGFloat
|
let radius: CGFloat
|
||||||
let breathe: (amount: CGFloat, speed: Double)
|
let breathe: (amount: CGFloat, speed: Double)
|
||||||
let opacity: Double
|
let opacity: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the
|
|
||||||
/// field shifts within one temperature instead of strobing through the rainbow.
|
|
||||||
private static let blobs: [Blob] = [
|
private static let blobs: [Blob] = [
|
||||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||||
@@ -110,49 +214,18 @@ struct GamepadScreenBackground: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if reduceMotion {
|
|
||||||
field(at: 0)
|
|
||||||
} else {
|
|
||||||
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
|
|
||||||
// of a battery-fed couch device vs. the default display rate.
|
|
||||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
|
||||||
field(at: context.date.timeIntervalSinceReferenceDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func field(at t: TimeInterval) -> some View {
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let side = max(geo.size.width, geo.size.height)
|
let side = max(geo.size.width, geo.size.height)
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black
|
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||||
ZStack {
|
blobView(Self.blobs[i], in: geo.size, side: side)
|
||||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
|
||||||
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// ±10° over ~5 min — the whole field very slowly warms and cools.
|
|
||||||
.hueRotation(.degrees(sin(t * 0.021) * 10))
|
|
||||||
// Composite the additive blobs offscreen once instead of per-layer.
|
|
||||||
.drawingGroup()
|
|
||||||
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
|
|
||||||
// near-black, whatever the blobs are doing behind them.
|
|
||||||
LinearGradient(
|
|
||||||
stops: [
|
|
||||||
.init(color: .black.opacity(0.55), location: 0),
|
|
||||||
.init(color: .black.opacity(0.15), location: 0.35),
|
|
||||||
.init(color: .black.opacity(0.20), location: 0.65),
|
|
||||||
.init(color: .black.opacity(0.60), location: 1),
|
|
||||||
],
|
|
||||||
startPoint: .top, endPoint: .bottom)
|
|
||||||
}
|
}
|
||||||
|
.drawingGroup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
|
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
|
||||||
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||||
let r = side * blob.radius
|
let r = side * blob.radius
|
||||||
@@ -168,28 +241,62 @@ struct GamepadScreenBackground: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
|
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
||||||
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
|
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
||||||
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
|
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
||||||
|
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
||||||
struct GamepadTrayScrim: View {
|
struct GamepadTrayScrim: View {
|
||||||
let edge: VerticalEdge
|
let edge: VerticalEdge
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LinearGradient(
|
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
||||||
stops: [
|
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
||||||
.init(color: .black.opacity(0.92), location: 0),
|
Rectangle()
|
||||||
.init(color: .black.opacity(0.85), location: 0.55),
|
.fill(.ultraThinMaterial)
|
||||||
.init(color: .black.opacity(0), location: 1),
|
// These trays always sit on the dark console UI; force dark so the material frosts dark
|
||||||
],
|
// (white text stays legible) regardless of the system appearance.
|
||||||
startPoint: edge == .top ? .top : .bottom,
|
.environment(\.colorScheme, .dark)
|
||||||
endPoint: edge == .top ? .bottom : .top)
|
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||||||
|
.mask {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black, location: 0),
|
||||||
|
.init(color: .black.opacity(0.9), location: 0.5),
|
||||||
|
.init(color: .clear, location: 1),
|
||||||
|
],
|
||||||
|
startPoint: fromEdge, endPoint: toContent)
|
||||||
|
}
|
||||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||||
// text always sits on the near-opaque part, rows dim before they reach it.
|
// text always sits on the strong part, rows blur out before they reach it.
|
||||||
.padding(edge == .top ? .bottom : .top, -32)
|
.padding(edge == .top ? .bottom : .top, -32)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||||||
|
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||||||
|
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||||||
|
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||||||
|
/// so the rows read as glass while the screen stays restful.
|
||||||
|
struct GamepadFormBackground: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||||||
|
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||||||
|
// (fraction radii) so the glow scale tracks the window on any screen.
|
||||||
|
EllipticalGradient(
|
||||||
|
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||||||
|
center: UnitPoint(x: 0.26, y: 0.14),
|
||||||
|
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||||
|
EllipticalGradient(
|
||||||
|
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||||||
|
center: UnitPoint(x: 0.82, y: 0.9),
|
||||||
|
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||||
/// when the pad or its battery state changes.
|
/// when the pad or its battery state changes.
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ private struct HomeTile: Identifiable {
|
|||||||
var hasLibrary = false
|
var hasLibrary = false
|
||||||
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||||
var icon: String?
|
var icon: String?
|
||||||
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
/// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first.
|
||||||
var showsStatus = true
|
var canWake = false
|
||||||
let activate: () -> Void
|
let activate: () -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,9 @@ struct GamepadHomeView: View {
|
|||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
@ObservedObject var discovery: HostDiscovery
|
@ObservedObject var discovery: HostDiscovery
|
||||||
@Binding var libraryTarget: StoredHost?
|
@Binding var libraryTarget: StoredHost?
|
||||||
|
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||||
|
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
let connect: (StoredHost) -> Void
|
let connect: (StoredHost) -> Void
|
||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
|
|
||||||
@@ -84,8 +87,11 @@ struct GamepadHomeView: View {
|
|||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
GamepadHintBar(hints: hints)
|
GamepadHintBar(hints: hints)
|
||||||
.padding(.leading, 22)
|
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||||
.padding(.vertical, compact ? 6 : 10)
|
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||||
|
.padding(.leading, compact ? 12 : 18)
|
||||||
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
|
.padding(.top, compact ? 4 : 8)
|
||||||
}
|
}
|
||||||
.background { GamepadScreenBackground() }
|
.background { GamepadScreenBackground() }
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
@@ -115,13 +121,13 @@ struct GamepadHomeView: View {
|
|||||||
|
|
||||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||||
let cardWidth = min(340, size.width * 0.84)
|
let cardWidth = min(340, size.width * 0.84)
|
||||||
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||||
// the strip + detail always fit the region the safe-area insets leave.
|
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||||
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
||||||
|
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||||
VStack(spacing: compact ? 8 : 10) {
|
VStack(spacing: compact ? 8 : 10) {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||||
detailPanel
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -155,9 +161,9 @@ struct GamepadHomeView: View {
|
|||||||
onActivate: { $0.activate() },
|
onActivate: { $0.activate() },
|
||||||
onSecondary: { openLibraryForSelected() },
|
onSecondary: { openLibraryForSelected() },
|
||||||
onTertiary: { showSettings = true },
|
onTertiary: { showSettings = true },
|
||||||
// Stop consuming the controller while another screen is presented on top — otherwise
|
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
||||||
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
||||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
||||||
) { tile in
|
) { tile in
|
||||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||||
}
|
}
|
||||||
@@ -186,49 +192,14 @@ struct GamepadHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
|
||||||
/// doesn't jump as the selection changes.
|
|
||||||
@ViewBuilder private var detailPanel: some View {
|
|
||||||
let tile = tiles.first { $0.id == selection }
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Text(tile?.title ?? " ")
|
|
||||||
.font(.geist(22, .bold, relativeTo: .title2))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Text(tile?.subtitle ?? " ")
|
|
||||||
.font(.geist(13, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
|
||||||
if let tile, tile.showsStatus {
|
|
||||||
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.animation(.smooth(duration: 0.25), value: selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statusPill(online: Bool, paired: Bool) -> some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle()
|
|
||||||
.fill(online ? Color.green : Color.white.opacity(0.35))
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
Text(online ? "ONLINE" : "OFFLINE")
|
|
||||||
if paired { Text("· PAIRED") }
|
|
||||||
}
|
|
||||||
.font(.geist(11, .medium, relativeTo: .caption2))
|
|
||||||
.tracking(0.8)
|
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||||
|
|
||||||
private var hints: [GamepadHint] {
|
private var hints: [GamepadHint] {
|
||||||
let selected = tiles.first { $0.id == selection }
|
let selected = tiles.first { $0.id == selection }
|
||||||
var hints = [GamepadHint(
|
var hints = [GamepadHint(
|
||||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||||
text: selected?.id == .addHost ? "Add Host" : "Connect")]
|
text: selected?.id == .addHost ? "Add Host"
|
||||||
|
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||||
if libraryEnabled, selected?.hasLibrary == true {
|
if libraryEnabled, selected?.hasLibrary == true {
|
||||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||||
}
|
}
|
||||||
@@ -252,6 +223,8 @@ struct GamepadHomeView: View {
|
|||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
|
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||||
|
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
}
|
}
|
||||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||||
@@ -267,7 +240,6 @@ struct GamepadHomeView: View {
|
|||||||
title: "Add Host",
|
title: "Add Host",
|
||||||
subtitle: "Register a host by address",
|
subtitle: "Register a host by address",
|
||||||
icon: "plus",
|
icon: "plus",
|
||||||
showsStatus: false,
|
|
||||||
activate: { showAddHost = true })
|
activate: { showAddHost = true })
|
||||||
return saved + discovered + [add]
|
return saved + discovered + [add]
|
||||||
}
|
}
|
||||||
@@ -291,14 +263,23 @@ private struct GamepadHostTile: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
monogramBadge
|
monogramBadge
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if tile.isOnline {
|
// The status the removed detail panel used to spell out, now on the card itself: a
|
||||||
Circle()
|
// lock for a paired (pinned-identity) host + a green pip when it's live on the LAN.
|
||||||
.fill(Color.green)
|
HStack(spacing: 7) {
|
||||||
.frame(width: 9, height: 9)
|
if tile.isPaired {
|
||||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
if tile.isOnline {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.green)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -315,11 +296,11 @@ private struct GamepadHostTile: View {
|
|||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
.background {
|
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||||
.fill(.ultraThinMaterial)
|
.consoleGlass(
|
||||||
.environment(\.colorScheme, .dark)
|
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
||||||
}
|
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
@@ -328,7 +309,6 @@ private struct GamepadHostTile: View {
|
|||||||
startPoint: .top, endPoint: .bottom),
|
startPoint: .top, endPoint: .bottom),
|
||||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
||||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,13 @@ struct HomeView: View {
|
|||||||
let onPaired: (StoredHost, Data) -> Void
|
let onPaired: (StoredHost, Data) -> Void
|
||||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
/// Picked a title in the (experimental) library — start a session that launches it.
|
||||||
let onLaunchTitle: (StoredHost, String) -> Void
|
let onLaunchTitle: (StoredHost, String) -> Void
|
||||||
|
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||||
|
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||||
|
let wake: (StoredHost) -> Void
|
||||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
|
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||||
|
@State private var editTarget: StoredHost?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -126,6 +131,13 @@ struct HomeView: View {
|
|||||||
.sheet(isPresented: $showAddHost) {
|
.sheet(isPresented: $showAddHost) {
|
||||||
AddHostSheet { store.add($0) }
|
AddHostSheet { store.add($0) }
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editTarget) { host in
|
||||||
|
// Prefill the MAC from the live advert when the host hasn't stored one yet.
|
||||||
|
AddHostSheet(
|
||||||
|
existing: host,
|
||||||
|
suggestedMacs: discovery.hosts.first { host.matches($0) }?.macAddresses ?? [],
|
||||||
|
onSave: { store.update($0) })
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||||
@@ -155,13 +167,8 @@ struct HomeView: View {
|
|||||||
onForget: { store.forgetIdentity(host) },
|
onForget: { store.forgetIdentity(host) },
|
||||||
onRemove: { store.remove(host) },
|
onRemove: { store.remove(host) },
|
||||||
onBrowseLibrary: onBrowseLibrary,
|
onBrowseLibrary: onBrowseLibrary,
|
||||||
onWake: {
|
onWake: { wake(host) },
|
||||||
let macs = host.wakeMacs
|
onEdit: { editTarget = host })
|
||||||
let ip = host.address
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ struct HostCardView: View {
|
|||||||
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
||||||
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||||
var onWake: (() -> Void)? = nil
|
var onWake: (() -> Void)? = nil
|
||||||
|
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||||
|
var onEdit: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
@@ -136,6 +138,9 @@ struct HostCardView: View {
|
|||||||
#endif
|
#endif
|
||||||
.disabled(isBusy)
|
.disabled(isBusy)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
if let onEdit {
|
||||||
|
Button("Edit…", systemImage: "pencil", action: onEdit)
|
||||||
|
}
|
||||||
Button("Pair with PIN…", action: onPair)
|
Button("Pair with PIN…", action: onPair)
|
||||||
Button("Test Network Speed…", action: onSpeedTest)
|
Button("Test Network Speed…", action: onSpeedTest)
|
||||||
if let onBrowseLibrary {
|
if let onBrowseLibrary {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
||||||
|
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
||||||
|
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
||||||
|
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WakeOverlay: View {
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let w = waker.waking {
|
||||||
|
ZStack {
|
||||||
|
// Dim + swallow input to the home behind it.
|
||||||
|
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {}
|
||||||
|
card(w)
|
||||||
|
.frame(maxWidth: 380)
|
||||||
|
.padding(28)
|
||||||
|
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.transition(.opacity)
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
.background { WakeControllerInput(waker: waker) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
if w.timedOut {
|
||||||
|
Image(systemName: "moon.zzz.fill")
|
||||||
|
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
||||||
|
Text("\(w.hostName) didn't wake")
|
||||||
|
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
Text("It may still be booting, or it's powered off / off this network.")
|
||||||
|
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||||
|
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
} else {
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Waking \(w.hostName)…")
|
||||||
|
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
Text("Waiting for it to come online · \(w.seconds)s")
|
||||||
|
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
.monospacedDigit()
|
||||||
|
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
||||||
|
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
||||||
|
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
||||||
|
private struct WakeControllerInput: View {
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
input.onBack = { waker.cancel() }
|
||||||
|
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
.onDisappear { input.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -18,23 +18,47 @@ struct ShotScene {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum ShotScenes {
|
enum ShotScenes {
|
||||||
static let all: [ShotScene] = [
|
static var all: [ShotScene] {
|
||||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
var scenes: [ShotScene] = [
|
||||||
AnyView(ShotStreamHero())
|
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||||
},
|
AnyView(ShotStreamHero())
|
||||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
},
|
||||||
AnyView(ShotHome())
|
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||||
},
|
AnyView(ShotHome())
|
||||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
},
|
||||||
AnyView(ShotPair())
|
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||||
},
|
AnyView(ShotPair())
|
||||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
},
|
||||||
AnyView(ShotTrust())
|
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||||
},
|
AnyView(ShotTrust())
|
||||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
},
|
||||||
AnyView(ShotSettings())
|
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||||
},
|
AnyView(ShotSettings())
|
||||||
]
|
},
|
||||||
|
]
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||||
|
// for eyeballing the Liquid Glass host tiles + settings rows.
|
||||||
|
scenes += [
|
||||||
|
ShotScene(name: "06-gamepad-home", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotGamepadHome())
|
||||||
|
},
|
||||||
|
ShotScene(name: "07-gamepad-settings", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotGamepadSettings())
|
||||||
|
},
|
||||||
|
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotGamepadAddHost())
|
||||||
|
},
|
||||||
|
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotWaking())
|
||||||
|
},
|
||||||
|
]
|
||||||
|
#endif
|
||||||
|
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotEditHost())
|
||||||
|
})
|
||||||
|
return scenes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mock data
|
// MARK: - Mock data
|
||||||
@@ -75,7 +99,7 @@ private struct ShotHome: View {
|
|||||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
connect: { _ in }, connectDiscovered: { _ in },
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||||
#else
|
#else
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
@@ -83,11 +107,77 @@ private struct ShotHome: View {
|
|||||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
showSettings: .constant(false),
|
showSettings: .constant(false),
|
||||||
connect: { _ in }, connectDiscovered: { _ in },
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private struct ShotGamepadHome: View {
|
||||||
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
|
@StateObject private var model = SessionModel()
|
||||||
|
@StateObject private var discovery = HostDiscovery()
|
||||||
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: .constant(nil), waker: waker,
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShotGamepadSettings: View {
|
||||||
|
var body: some View { GamepadSettingsView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShotGamepadAddHost: View {
|
||||||
|
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShotWaking: View {
|
||||||
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
|
@StateObject private var model = SessionModel()
|
||||||
|
@StateObject private var discovery = HostDiscovery()
|
||||||
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: .constant(nil), waker: waker,
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in }
|
||||||
|
)
|
||||||
|
.overlay { WakeOverlay(waker: waker) }
|
||||||
|
.onAppear {
|
||||||
|
waker.debugSet(.init(
|
||||||
|
hostID: store.hosts.first?.id ?? UUID(),
|
||||||
|
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
|
||||||
|
|
||||||
|
private struct ShotEditHost: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||||
|
AddHostSheet(
|
||||||
|
existing: StoredHost(
|
||||||
|
name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
|
pinnedSHA256: ShotMock.fingerprint, macAddresses: ["a4:b1:c2:d3:e4:f5"]),
|
||||||
|
onSave: { _ in })
|
||||||
|
#if os(macOS)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(radius: 40, y: 16)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private struct ShotSettings: View {
|
private struct ShotSettings: View {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Wake a sleeping host and WAIT for it to come back before proceeding.
|
||||||
|
//
|
||||||
|
// A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||||
|
// advertising on mDNS again — far longer than a connect attempt will sit. The old path fired a
|
||||||
|
// packet and immediately dialed, so a genuinely-asleep host just failed. This drives a visible
|
||||||
|
// "Waking…" state instead: it (re-)sends the packet, polls the host's mDNS presence once a second,
|
||||||
|
// and on success runs `onOnline` (the real connect for a Wake-&-Connect, or nothing for an explicit
|
||||||
|
// wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class HostWaker: ObservableObject {
|
||||||
|
struct Waking: Equatable {
|
||||||
|
let hostID: UUID
|
||||||
|
let hostName: String
|
||||||
|
/// Whether coming online chains into a connect (Wake & Connect) vs. just stopping.
|
||||||
|
let connectsAfter: Bool
|
||||||
|
var seconds = 0
|
||||||
|
var timedOut = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// nil = idle; non-nil drives `WakeOverlay`.
|
||||||
|
@Published private(set) var waking: Waking?
|
||||||
|
|
||||||
|
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||||
|
/// start can be a minute-plus.
|
||||||
|
private let timeoutSeconds = 90
|
||||||
|
/// Re-send the packet this often: a single one can be missed, and some NICs only wake on a fresh
|
||||||
|
/// packet after dropping into a deeper sleep state.
|
||||||
|
private let resendEverySeconds = 6
|
||||||
|
|
||||||
|
private var loop: Task<Void, Never>?
|
||||||
|
/// Captured so "Try Again" replays the exact same wait.
|
||||||
|
private var replay: (() -> Void)?
|
||||||
|
|
||||||
|
/// Wake `host` and wait for `isOnline()` to go true, then run `onOnline`. `macs`/`lastIP` target
|
||||||
|
/// the magic packet. No-ops straight to `onOnline` when there's nothing to wake with or the host
|
||||||
|
/// is already up (a race between the caller's check and here).
|
||||||
|
func start(
|
||||||
|
host: StoredHost, connectsAfter: Bool,
|
||||||
|
macs: [String], lastIP: String?,
|
||||||
|
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
guard !macs.isEmpty, !isOnline() else {
|
||||||
|
cancel()
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replay = { [weak self] in
|
||||||
|
self?.run(host: host, connectsAfter: connectsAfter, macs: macs, lastIP: lastIP,
|
||||||
|
isOnline: isOnline, onOnline: onOnline)
|
||||||
|
}
|
||||||
|
replay?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop waiting and dismiss the overlay (B / Cancel).
|
||||||
|
func cancel() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = nil
|
||||||
|
replay = nil
|
||||||
|
waking = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the wait after a timeout (A / Try Again).
|
||||||
|
func retry() { replay?() }
|
||||||
|
|
||||||
|
private func run(
|
||||||
|
host: StoredHost, connectsAfter: Bool, macs: [String], lastIP: String?,
|
||||||
|
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
loop?.cancel()
|
||||||
|
waking = Waking(hostID: host.id, hostName: host.displayName, connectsAfter: connectsAfter)
|
||||||
|
let timeout = timeoutSeconds
|
||||||
|
let resend = resendEverySeconds
|
||||||
|
loop = Task { [weak self] in
|
||||||
|
var elapsed = 0
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if elapsed % resend == 0 { Self.sendPacket(macs: macs, lastIP: lastIP) }
|
||||||
|
if isOnline() {
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.waking = nil
|
||||||
|
self.loop = nil
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if elapsed >= timeout {
|
||||||
|
self?.waking?.timedOut = true
|
||||||
|
self?.loop = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
elapsed += 1
|
||||||
|
self?.waking?.seconds = elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking sends (see PunktfunkConnection.wakeOnLAN) — off the main thread.
|
||||||
|
private static func sendPacket(macs: [String], lastIP: String?) {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: lastIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Force a static waking state for the screenshot harness (no timers, no packets).
|
||||||
|
func debugSet(_ w: Waking) { waking = w }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -81,13 +81,17 @@ struct GamepadSettingsView: View {
|
|||||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
.padding(.leading, 22)
|
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||||
|
.padding(.leading, compact ? 12 : 18)
|
||||||
.padding(.trailing, 22)
|
.padding(.trailing, 22)
|
||||||
.padding(.vertical, compact ? 6 : 10)
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
|
.padding(.top, compact ? 6 : 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
.background { GamepadTrayScrim(edge: .bottom) }
|
||||||
}
|
}
|
||||||
.background { GamepadScreenBackground() }
|
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||||||
|
// glass rows are the only material on the screen.
|
||||||
|
.background { GamepadFormBackground() }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
gamepads.refresh()
|
gamepads.refresh()
|
||||||
gamepads.startDiscovery()
|
gamepads.startDiscovery()
|
||||||
@@ -148,13 +152,14 @@ struct GamepadSettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, 13)
|
||||||
.background {
|
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
.consoleGlass(
|
||||||
.fill(.white.opacity(focused ? 0.1 : 0))
|
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||||
}
|
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||||
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
|
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||||
}
|
}
|
||||||
.scaleEffect(focused ? 1.0 : 0.98)
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
.animation(.smooth(duration: 0.18), value: focused)
|
.animation(.smooth(duration: 0.18), value: focused)
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ final class HostStore: ObservableObject {
|
|||||||
hosts.removeAll { $0.id == host.id }
|
hosts.removeAll { $0.id == host.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace a saved host in place (the edit sheet) — matched by id, so identity/pin/last-connected
|
||||||
|
/// carried on the passed value are preserved.
|
||||||
|
func update(_ host: StoredHost) {
|
||||||
|
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
|
||||||
|
hosts[i] = host
|
||||||
|
}
|
||||||
|
|
||||||
func markConnected(_ hostID: UUID) {
|
func markConnected(_ hostID: UUID) {
|
||||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||||
hosts[i].lastConnected = Date()
|
hosts[i].lastConnected = Date()
|
||||||
|
|||||||
@@ -67,3 +67,41 @@ extension View {
|
|||||||
modifier(GlassProminentButton())
|
modifier(GlassProminentButton())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Console glass (gamepad host tiles + settings rows)
|
||||||
|
|
||||||
|
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and
|
||||||
|
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
|
||||||
|
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
|
||||||
|
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
|
||||||
|
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces
|
||||||
|
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
|
||||||
|
private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||||
|
let shape: S
|
||||||
|
var tint: Color?
|
||||||
|
var interactive = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||||
|
content.glassEffect(glass, in: shape)
|
||||||
|
} else {
|
||||||
|
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, macOS 26, tvOS 26, *)
|
||||||
|
private var glass: Glass {
|
||||||
|
var g: Glass = .regular
|
||||||
|
if let tint { g = g.tint(tint) }
|
||||||
|
if interactive { g = g.interactive() }
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||||
|
/// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||||
|
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
||||||
|
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ struct PairSheet: View {
|
|||||||
TextField(
|
TextField(
|
||||||
"PIN", text: $pin,
|
"PIN", text: $pin,
|
||||||
prompt: Text("Shown in the host's web console"))
|
prompt: Text("Shown in the host's web console"))
|
||||||
.font(.system(.title3, design: .monospaced))
|
.font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
#endif
|
#endif
|
||||||
@@ -134,6 +134,11 @@ struct PairSheet: View {
|
|||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
// Bring the grouped form's default system text down to the app's Geist scale so the sheet
|
||||||
|
// doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own
|
||||||
|
// explicit Geist Mono font.
|
||||||
|
.font(.geist(12, relativeTo: .callout))
|
||||||
|
.controlSize(.small)
|
||||||
#endif
|
#endif
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
|
|||||||
Reference in New Issue
Block a user