Merge origin/main (tvOS client work) with host EIS/attach + macOS-input fixes
ci / rust (push) Has been cancelled

This commit is contained in:
2026-06-11 13:05:02 +00:00
47 changed files with 911 additions and 33 deletions
@@ -0,0 +1,13 @@
{
"images": [
{
"filename": "back@1x.png",
"idiom": "tv",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,13 @@
{
"images": [
{
"filename": "circle1@1x.png",
"idiom": "tv",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,13 @@
{
"images": [
{
"filename": "circle2@1x.png",
"idiom": "tv",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,20 @@
{
"layers": [
{
"filename": "Front.imagestacklayer"
},
{
"filename": "Circle2.imagestacklayer"
},
{
"filename": "Circle1.imagestacklayer"
},
{
"filename": "Back.imagestacklayer"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,13 @@
{
"images": [
{
"filename": "front@1x.png",
"idiom": "tv",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "back@1x.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "back@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "circle1@1x.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "circle1@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "circle2@1x.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "circle2@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,20 @@
{
"layers": [
{
"filename": "Front.imagestacklayer"
},
{
"filename": "Circle2.imagestacklayer"
},
{
"filename": "Circle1.imagestacklayer"
},
{
"filename": "Back.imagestacklayer"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "front@1x.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "front@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,32 @@
{
"assets": [
{
"filename": "App Icon - App Store.imagestack",
"idiom": "tv",
"role": "primary-app-icon",
"size": "1280x768"
},
{
"filename": "App Icon.imagestack",
"idiom": "tv",
"role": "primary-app-icon",
"size": "400x240"
},
{
"filename": "Top Shelf Image Wide.imageset",
"idiom": "tv",
"role": "top-shelf-image-wide",
"size": "2320x720"
},
{
"filename": "Top Shelf Image.imageset",
"idiom": "tv",
"role": "top-shelf-image",
"size": "1920x720"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "shelf-wide@1x.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "shelf-wide@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "shelf@1x.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "shelf@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

+3
View File
@@ -24,6 +24,9 @@ let package = Package(
] ]
), ),
// Development app shell (swift run PunktfunkClient): connect form stream + input. // Development app shell (swift run PunktfunkClient): connect form stream + input.
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]), .executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]), .testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]),
] ]
@@ -10,6 +10,7 @@
AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; }; AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; };
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; }; BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; }; CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -53,6 +54,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CC0000000000000000000005 /* PunktfunkKit in Frameworks */, CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -148,6 +150,7 @@
name = "Punktfunk-tvOS"; name = "Punktfunk-tvOS";
packageProductDependencies = ( packageProductDependencies = (
CC0000000000000000000006 /* PunktfunkKit */, CC0000000000000000000006 /* PunktfunkKit */,
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
); );
productName = "Punktfunk-tvOS"; productName = "Punktfunk-tvOS";
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */; productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
@@ -177,6 +180,7 @@
mainGroup = AA0000000000000000000007; mainGroup = AA0000000000000000000007;
packageReferences = ( packageReferences = (
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */, AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = AA0000000000000000000008 /* Products */; productRefGroup = AA0000000000000000000008 /* Products */;
@@ -487,8 +491,10 @@
CC0000000000000000000012 /* Debug */ = { CC0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -513,8 +519,10 @@
CC0000000000000000000013 /* Release */ = { CC0000000000000000000013 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -539,15 +547,6 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CC0000000000000000000012 /* Debug */,
CC0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk" */ = { AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -575,6 +574,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CC0000000000000000000012 /* Debug */,
CC0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
@@ -584,6 +592,17 @@
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/davdroman/swiftui-navigation-transitions";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.18.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
AA0000000000000000000006 /* PunktfunkKit */ = { AA0000000000000000000006 /* PunktfunkKit */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
@@ -597,6 +616,11 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = PunktfunkKit; productName = PunktfunkKit;
}; };
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
isa = XCSwiftPackageProductDependency;
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
productName = SwiftUINavigationTransitions;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = AA000000000000000000000D /* Project object */; rootObject = AA000000000000000000000D /* Project object */;
@@ -0,0 +1,60 @@
{
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
"pins" : [
{
"identity" : "objc-runtime-tools",
"kind" : "remoteSourceControl",
"location" : "https://github.com/davdroman/objc-runtime-tools",
"state" : {
"revision" : "04715d0c98d366d7000be32c0c81b4ba87001910",
"version" : "0.5.1"
}
},
{
"identity" : "swift-once-macro",
"kind" : "remoteSourceControl",
"location" : "https://github.com/davdroman/swift-once-macro",
"state" : {
"revision" : "5f9d4e77cd95335fe14b44064fcf7f96e8ed56a0",
"version" : "1.1.1"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1",
"version" : "603.0.2"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/swiftui-introspect",
"state" : {
"revision" : "aead9358a55f635d62d885aeb9105752c0213aec",
"version" : "27.0.0-beta.1"
}
},
{
"identity" : "swiftui-navigation-transitions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/davdroman/swiftui-navigation-transitions",
"state" : {
"revision" : "78287a0adf2ed35c40dc6445d0c7fc6fba236076",
"version" : "0.18.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b",
"version" : "1.10.0"
}
}
],
"version" : 3
}
+2 -1
View File
@@ -146,7 +146,8 @@ signing, bundle id `io.unom.punktfunk`. Notes:
7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist 7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist
both PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN both PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN
the host prints when it ARMS pairing (`--allow-pairing`/`--require-pairing`; one PIN the host prints when it ARMS pairing (`--allow-pairing`/`--require-pairing`; one PIN
per arming window, shown at startup — the user reads it before pairing). Returns the per arming window, surfaced in the host's web console — port 3000 → Pairing — and
printed at startup; the user reads it before pairing). Returns the
host's VERIFIED fingerprint; persist it and pass `pinSHA256:` + `identity:` to every host's VERIFIED fingerprint; persist it and pass `pinSHA256:` + `identity:` to every
connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline
dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. `PunktfunkClient` implements both flows: dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. `PunktfunkClient` implements both flows:
@@ -8,30 +8,86 @@ struct AddHostSheet: View {
@State private var name = "" @State private var name = ""
@State private var address = "" @State private var address = ""
@State private var port = 9777 @State private var port = 9777
#if os(tvOS)
private enum EditField: String, Identifiable {
case name, address, port
var id: String { rawValue }
}
@State private var editing: EditField?
#endif
let onAdd: (StoredHost) -> Void let onAdd: (StoredHost) -> Void
var body: some View { var body: some View {
#if os(tvOS)
// No inline text editing on tvOS Settings-style value rows; pressing one
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
VStack(spacing: 24) {
TVFieldRow(
label: "Name", value: name, placeholder: "Optional"
) { editing = .name }
TVFieldRow(
label: "Address", value: address, placeholder: "IP or hostname"
) { editing = .address }
TVFieldRow(
label: "Port", value: String(port), placeholder: ""
) { editing = .port }
HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() }
Button("Add Host") { add() }
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Add Host")
.fullScreenCover(item: $editing) { field in
switch field {
case .name:
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
name = $0
editing = nil
}
case .address:
TVTextEntry(title: "IP or hostname", text: address) {
address = $0.trimmingCharacters(in: .whitespaces)
editing = nil
}
case .port:
TVTextEntry(
title: "Port", text: String(port), keyboardType: .numberPad
) {
if let value = Int($0), (1...65535).contains(value) {
port = value
}
editing = nil
}
}
}
#else
VStack(spacing: 0) { VStack(spacing: 0) {
Form { Form {
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)
// tvOS floats the label above a non-empty field INSIDE the pill,
// shoving the value off-center the field is always prefilled
// here, so drop the label there.
.labelsHidden()
#endif
} }
.formStyle(.grouped) #if !os(tvOS)
.formStyle(.grouped)
#endif
HStack { HStack {
Button("Cancel", role: .cancel) { dismiss() } Button("Cancel", role: .cancel) { dismiss() }
#if !os(tvOS) #if !os(tvOS)
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
#endif #endif
Spacer() Spacer()
Button("Add Host") { Button("Add Host") { add() }
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
}
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
#if !os(tvOS) #if !os(tvOS)
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@@ -47,5 +103,14 @@ struct AddHostSheet: View {
.frame(width: 380) .frame(width: 380)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
#endif #endif
#endif
}
private func add() {
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
} }
} }
@@ -13,6 +13,9 @@ import AppKit
#endif #endif
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(tvOS)
import SwiftUINavigationTransitions
#endif
struct ContentView: View { struct ContentView: View {
@StateObject private var model = SessionModel() @StateObject private var model = SessionModel()
@@ -55,6 +58,7 @@ struct ContentView: View {
// On the outer Group so the sheet survives the trust-prompt home transition // On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop // (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session). // is sequential, a pairing connection would queue behind the live session).
#if !os(tvOS)
.sheet(item: $pairingTarget) { host in .sheet(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in PairSheet(host: host) { fingerprint in
// Backstop against a stale ceremony surfacing after dismissal (PairSheet // Backstop against a stale ceremony surfacing after dismissal (PairSheet
@@ -66,6 +70,7 @@ struct ContentView: View {
connect(pinned) connect(pinned)
} }
} }
#endif
} }
private var sessionView: some View { private var sessionView: some View {
@@ -110,18 +115,54 @@ struct ContentView: View {
emptyState emptyState
} else { } else {
ScrollView { ScrollView {
LazyVGrid(columns: gridColumns, spacing: 16) { LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(store.hosts) { host in ForEach(store.hosts) { host in
hostCard(host) hostCard(host)
} }
} }
.padding() .padding()
#if os(tvOS)
// Actions live below the hosts, not between them.
HStack(spacing: 32) {
Button {
showAddHost = true
} label: {
Label("Add Host", systemImage: "plus")
}
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
}
.padding(.top, 24)
#endif
} }
} }
} }
.navigationTitle("Punktfunkempfänger") .navigationTitle("Punktfunkempfänger")
#if os(tvOS)
// Pushed routes the Settings-app navigation feel (push animation, Menu
// pops) instead of modal overlays.
.navigationDestination(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
.navigationDestination(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in
guard pairingTarget?.id == host.id else { return }
store.pin(host.id, fingerprint: fingerprint)
var pinned = host
pinned.pinnedSHA256 = fingerprint
connect(pinned)
}
}
#endif
#if !os(tvOS)
.toolbar { .toolbar {
#if !os(macOS) #if os(iOS)
// Adjacent trailing items share one glass pill (the system default). // Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton } ToolbarItem(placement: .topBarTrailing) { settingsButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton } ToolbarItem(placement: .topBarTrailing) { addHostButton }
@@ -138,14 +179,24 @@ struct ContentView: View {
} }
#endif #endif
} }
#endif
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 480, minHeight: 360) .frame(minWidth: 480, minHeight: 360)
#endif #endif
#if os(tvOS)
// The Settings-app slide for every push in this stack (top-level routes AND
// the pickers' drill-ins) SwiftUI's default on tvOS is a bare crossfade.
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio settles fast
// with just a hint of life, no visible overshoot ping-pong.
.customNavigationTransition(
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
#endif
#if !os(tvOS)
.sheet(isPresented: $showAddHost) { .sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) } AddHostSheet { store.add($0) }
} }
#if !os(macOS) #if os(iOS)
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
NavigationStack { NavigationStack {
SettingsView() SettingsView()
@@ -156,6 +207,7 @@ struct ContentView: View {
} }
} }
#endif #endif
#endif
.alert( .alert(
"Connection failed", "Connection failed",
isPresented: Binding( isPresented: Binding(
@@ -175,11 +227,21 @@ struct ContentView: View {
private var gridColumns: [GridItem] { private var gridColumns: [GridItem] {
#if os(macOS) #if os(macOS)
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)] [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
#elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)]
#else #else
[GridItem(.adaptive(minimum: 280), spacing: 16)] [GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif #endif
} }
private var gridSpacing: CGFloat {
#if os(tvOS)
48 // the focused card scales up give it room instead of overlapping siblings
#else
16
#endif
}
private var addHostButton: some View { private var addHostButton: some View {
Button { Button {
showAddHost = true showAddHost = true
@@ -209,6 +271,9 @@ struct ContentView: View {
#if os(iOS) #if os(iOS)
.controlSize(.large) .controlSize(.large)
#endif #endif
#if os(tvOS)
Button("Settings") { showSettings = true }
#endif
} }
} }
@@ -265,6 +330,9 @@ struct ContentView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, cardPadding) .padding(.vertical, cardPadding)
.padding(.horizontal, 12) .padding(.horizontal, 12)
#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.
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay { .overlay {
if host.id == mostRecentHostID { if host.id == mostRecentHostID {
@@ -272,6 +340,7 @@ struct ContentView: View {
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5) .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
} }
} }
#endif
} }
#if os(tvOS) #if os(tvOS)
.buttonStyle(.card) .buttonStyle(.card)
@@ -1,6 +1,6 @@
// PIN pairing sheet. The host, started with --allow-pairing (or --require-pairing), // PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000
// prints a short PIN at startup ("PAIRING ARMED enter this PIN on the client to // Pairing; also printed in the host's log when armed via --allow-pairing); the user
// pair"); the user types it here. The ceremony is SPAKE2, so a wrong PIN buys an // types it here. The ceremony is SPAKE2, so a wrong PIN buys an
// attacker exactly one online guess for the user a typo just means "try again" (the // attacker exactly one online guess for the user a typo just means "try again" (the
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED // host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this // fingerprint: the caller pins it, no manual comparison needed, and the host stores this
@@ -33,12 +33,76 @@ struct PairSheet: View {
@State private var busy = false @State private var busy = false
@State private var errorText: String? @State private var errorText: String?
@State private var token = CeremonyToken() @State private var token = CeremonyToken()
#if os(tvOS)
private enum EditField: String, Identifiable {
case pin, clientName
var id: String { rawValue }
}
@State private var editing: EditField?
#endif
var body: some View { var body: some View {
#if os(tvOS)
VStack(spacing: 24) {
Text("The PIN is shown in the host's web console "
+ "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TVFieldRow(
label: "PIN", value: pin, placeholder: "Shown in the host's web console"
) { editing = .pin }
TVFieldRow(
label: "Device name", value: clientName, placeholder: "Apple TV"
) { editing = .clientName }
if let errorText {
Text(errorText)
.font(.callout)
.foregroundStyle(.red)
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
token.cancelled = true
dismiss()
}
if busy {
ProgressView()
}
Button("Pair & Connect") { runCeremony() }
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Pair with \(host.displayName)")
.onDisappear { token.cancelled = true }
.fullScreenCover(item: $editing) { field in
switch field {
case .pin:
TVTextEntry(
title: "PIN (shown in the host's web console)", text: pin,
keyboardType: .numberPad
) {
pin = $0.trimmingCharacters(in: .whitespaces)
editing = nil
}
case .clientName:
TVTextEntry(title: "Device name", text: clientName) {
clientName = $0
editing = nil
}
}
}
#else
VStack(spacing: 0) { VStack(spacing: 0) {
Form { Form {
Section { Section {
TextField("PIN", text: $pin, prompt: Text("Shown in the host's log")) TextField(
"PIN", text: $pin,
prompt: Text("Shown in the host's web console"))
.font(.system(.title3, design: .monospaced)) .font(.system(.title3, design: .monospaced))
#if os(iOS) #if os(iOS)
.keyboardType(.numberPad) .keyboardType(.numberPad)
@@ -46,12 +110,15 @@ struct PairSheet: View {
TextField( TextField(
"Client name", text: $clientName, "Client name", text: $clientName,
prompt: Text("How the host lists this Mac")) prompt: Text("How the host lists this Mac"))
#if os(tvOS)
.labelsHidden() // prefilled tvOS floats the label off-center
#endif
} header: { } header: {
Label("Pair with \(host.displayName)", systemImage: "lock.shield") Label("Pair with \(host.displayName)", systemImage: "lock.shield")
.foregroundStyle(.tint) .foregroundStyle(.tint)
} footer: { } footer: {
Text("The host prints the PIN when pairing is armed " Text("The PIN is shown in the host's web console "
+ "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). " + "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint " + "Pairing verifies both sides at once — no fingerprint "
+ "comparison needed.") + "comparison needed.")
.font(.caption) .font(.caption)
@@ -65,7 +132,9 @@ struct PairSheet: View {
} }
} }
} }
.formStyle(.grouped) #if !os(tvOS)
.formStyle(.grouped)
#endif
HStack { HStack {
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
token.cancelled = true token.cancelled = true
@@ -98,6 +167,7 @@ struct PairSheet: View {
#endif #endif
.interactiveDismissDisabled(busy) .interactiveDismissDisabled(busy)
.onDisappear { token.cancelled = true } // any other dismissal path .onDisappear { token.cancelled = true } // any other dismissal path
#endif
} }
private func runCeremony() { private func runCeremony() {
@@ -126,16 +196,16 @@ struct PairSheet: View {
onPaired(fingerprint) onPaired(fingerprint)
dismiss() dismiss()
case .failure(PunktfunkClientError.wrongPIN): case .failure(PunktfunkClientError.wrongPIN):
errorText = "Wrong PIN — check the host's \u{201C}PAIRING ARMED\u{201D} " errorText = "Wrong PIN — check the host's web console (port 3000) "
+ "line and try again." + "and try again."
case .failure(is ClientIdentityStore.IdentityError): case .failure(is ClientIdentityStore.IdentityError):
errorText = "Can't store this Mac's identity in the Keychain, so the " errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login " + "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again." + "keychain and try again."
case .failure: case .failure:
errorText = "Pairing failed. Is the host reachable, armed with " errorText = "Pairing failed. Is the host reachable, pairing armed "
+ "--allow-pairing, and not mid-session? Retries are rate-limited " + "(web console → Pairing), and not mid-session? Retries are "
+ "to one per 2 seconds." + "rate-limited to one per 2 seconds."
} }
} }
} }
@@ -9,6 +9,7 @@ import PunktfunkKit
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.width") private var width = 1920
@AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.height") private var height = 1080
@AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.hz") private var hz = 60
@@ -22,6 +23,76 @@ struct SettingsView: View {
#endif #endif
var body: some View { var body: some View {
#if os(tvOS)
// Native tv pattern: no inline text entry (typing numbers with a remote is
// miserable and the inline field chrome fights the focus system). The mode is
// a preset picker; pickers push selection lists like the system Settings app.
tvBody
#else
sharedBody
#endif
}
#if os(tvOS)
private static let presets: [(label: String, tag: String)] = [
("720p @ 60", "1280x720x60"),
("1080p @ 60", "1920x1080x60"),
("4K @ 60", "3840x2160x60"),
]
private var modeTag: Binding<String> {
Binding(
get: { "\(width)x\(height)x\(hz)" },
set: { tag in
let parts = tag.split(separator: "x").compactMap { Int($0) }
guard parts.count == 3 else { return }
width = parts[0]
height = parts[1]
hz = parts[2]
})
}
private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds
let nativeTag = "\(Int(max(bounds.width, bounds.height)))x"
+ "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)"
var options = Self.presets
if !options.contains(where: { $0.tag == nativeTag }) {
options.insert(("This TV (native)", nativeTag), at: 0)
}
if !options.contains(where: { $0.tag == currentTag }) {
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
}
let compositors: [(label: String, tag: Int)] = [
("Automatic", 0),
("KWin (KDE Plasma)", 1),
("wlroots (Sway / Hyprland)", 2),
("Mutter (GNOME)", 3),
("gamescope", 4),
]
return ScrollView {
VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
TVSelectionRow(
title: "Compositor", options: compositors, selection: $compositor)
Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. A specific compositor is honored only if "
+ "available on the host.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
}
.frame(maxWidth: 1000)
.frame(maxWidth: .infinity)
.padding(60)
}
.navigationTitle("Settings")
}
#endif
private var sharedBody: some View {
Form { Form {
Section { Section {
HStack { HStack {
@@ -0,0 +1,144 @@
// The native tvOS text-entry experience: real tvOS apps never edit text inline
// selecting a field presents the SYSTEM full-screen keyboard (Apple's "Designing the
// Keyboard Input Experience"). UIKit gives that for free: a UITextField that becomes
// first responder presents the fullscreen keyboard UI with the field's placeholder as
// the prompt. SwiftUI's inline TextField on tvOS is an expanding pill with stray
// chrome this bridge replaces it everywhere on tvOS.
#if os(tvOS)
import SwiftUI
import UIKit
/// Present inside a fullScreenCover: immediately raises the system keyboard for one
/// value, then calls `onDone` with the result (also on Menu-button dismissal, with
/// whatever was typed so far match the system apps' "edits stick" behavior).
struct TVTextEntry: UIViewControllerRepresentable {
let title: String
let text: String
var keyboardType: UIKeyboardType = .default
let onDone: (String) -> Void
func makeUIViewController(context: Context) -> TVTextEntryController {
let controller = TVTextEntryController()
controller.configure(
title: title, text: text, keyboardType: keyboardType, onDone: onDone)
return controller
}
func updateUIViewController(_ controller: TVTextEntryController, context: Context) {}
}
final class TVTextEntryController: UIViewController, UITextFieldDelegate {
private let field = UITextField()
private var onDone: ((String) -> Void)?
private var finished = false
func configure(
title: String, text: String, keyboardType: UIKeyboardType,
onDone: @escaping (String) -> Void
) {
field.placeholder = title
field.text = text
field.keyboardType = keyboardType
field.returnKeyType = .done
self.onDone = onDone
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
field.delegate = self
view.addSubview(field) // must be in a window to become first responder
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
field.becomeFirstResponder() // presents the tvOS fullscreen keyboard
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
guard !finished else { return }
finished = true
onDone?(textField.text ?? "")
}
}
/// A Settings-app-style value row: label leading, current value trailing the whole
/// row is one system lozenge, and pressing it opens the fullscreen keyboard.
struct TVFieldRow: View {
let label: String
let value: String
let placeholder: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Text(label)
Spacer()
Text(value.isEmpty ? placeholder : value)
.foregroundStyle(.secondary)
}
}
}
}
/// A Settings-app-style selection screen: pushed list of option rows, checkmark on the
/// current value, selecting pops back. Replaces Picker(.navigationLink), whose internal
/// list renders rows in the focused (dark-text) style while the push animates.
struct TVSelectionList<Tag: Hashable>: View {
let title: String
let options: [(label: String, tag: Tag)]
@Binding var selection: Tag
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(options, id: \.tag) { option in
Button {
selection = option.tag
dismiss()
} label: {
HStack {
Text(option.label)
Spacer()
if option.tag == selection {
Image(systemName: "checkmark")
}
}
}
}
}
.frame(maxWidth: 900)
.frame(maxWidth: .infinity)
.padding(60)
}
.navigationTitle(title)
}
}
/// The pushing row for a TVSelectionList: label leading, current value trailing.
struct TVSelectionRow<Tag: Hashable>: View {
let title: String
let options: [(label: String, tag: Tag)]
@Binding var selection: Tag
var body: some View {
NavigationLink {
TVSelectionList(title: title, options: options, selection: $selection)
} label: {
HStack {
Text(title)
Spacer()
Text(options.first { $0.tag == selection }?.label ?? "")
.foregroundStyle(.secondary)
}
}
}
}
#endif
+91
View File
@@ -0,0 +1,91 @@
// Usage: swift scripts/render-tvos-icon.swift <layer-export-dir>
// "clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets"
// Icon Composer has no tvOS support tvOS wants flat parallax layers (brand assets),
// so this renders them from the same Affinity layer exports the .icon bundle uses.
//
// Renders the tvOS parallax icon layers from the flat Icon Composer layer exports:
// gradient background (the icon.json automatic-gradient violet) + the three art layers
// on transparent canvases, at every size the brand asset needs.
import AppKit
let export = CommandLine.arguments[1]
let outDir = CommandLine.arguments[2]
func bitmap(_ size: NSSize, draw: () -> Void) -> Data {
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
rep.size = size
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
draw()
NSGraphicsContext.restoreGraphicsState()
return rep.representation(using: .png, properties: [:])!
}
func write(_ data: Data, _ path: String) {
let url = URL(fileURLWithPath: outDir).appendingPathComponent(path)
try! FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try! data.write(to: url)
print(path)
}
// icon.json: automatic-gradient display-p3 (0.395, 0.306, 0.963) approximated as a
// vertical sRGB gradient around the brand violet.
func gradientPNG(_ size: NSSize) -> Data {
bitmap(size) {
let top = NSColor(srgbRed: 0.49, green: 0.42, blue: 0.97, alpha: 1)
let bottom = NSColor(srgbRed: 0.35, green: 0.26, blue: 0.91, alpha: 1)
NSGradient(starting: top, ending: bottom)!
.draw(in: NSRect(origin: .zero, size: size), angle: -90)
}
}
func layerPNG(_ name: String, _ size: NSSize, heightFraction: CGFloat) -> Data {
let image = NSImage(contentsOfFile: "\(export)/\(name)")!
return bitmap(size) {
let h = size.height * heightFraction
let rect = NSRect(x: (size.width - h) / 2, y: (size.height - h) / 2, width: h, height: h)
image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
}
}
let l1 = "punktfunk_Minimal_Icon-Composer_Layer-1.png" // light circle (back-most art)
let l2 = "punktfunk_Minimal_Icon-Composer_Layer-2.png" // dark circle
let l3 = "punktfunk_Minimal_Icon-Composer_Layer-3.png" // blob (front)
// App icon stacks: art at 92% of canvas height (tvOS crops edges in the focus effect).
for (stack, sizes) in [
("App Icon.imagestack", [("@1x", NSSize(width: 400, height: 240)),
("@2x", NSSize(width: 800, height: 480))]),
("App Icon - App Store.imagestack", [("@1x", NSSize(width: 1280, height: 768))]),
] {
for (suffix, size) in sizes {
write(gradientPNG(size), "\(stack)/Back.imagestacklayer/Content.imageset/back\(suffix).png")
write(layerPNG(l1, size, heightFraction: 0.92), "\(stack)/Circle1.imagestacklayer/Content.imageset/circle1\(suffix).png")
write(layerPNG(l2, size, heightFraction: 0.92), "\(stack)/Circle2.imagestacklayer/Content.imageset/circle2\(suffix).png")
write(layerPNG(l3, size, heightFraction: 0.92), "\(stack)/Front.imagestacklayer/Content.imageset/front\(suffix).png")
}
}
// Top shelf images: flat composite (no parallax stack), mark at 70% height.
func shelfPNG(_ size: NSSize) -> Data {
let images = [l1, l2, l3].map { NSImage(contentsOfFile: "\(export)/\($0)")! }
return bitmap(size) {
let top = NSColor(srgbRed: 0.49, green: 0.42, blue: 0.97, alpha: 1)
let bottom = NSColor(srgbRed: 0.35, green: 0.26, blue: 0.91, alpha: 1)
NSGradient(starting: top, ending: bottom)!
.draw(in: NSRect(origin: .zero, size: size), angle: -90)
let h = size.height * 0.7
let rect = NSRect(x: (size.width - h) / 2, y: (size.height - h) / 2, width: h, height: h)
for image in images {
image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
}
}
}
write(shelfPNG(NSSize(width: 1920, height: 720)), "Top Shelf Image.imageset/shelf@1x.png")
write(shelfPNG(NSSize(width: 3840, height: 1440)), "Top Shelf Image.imageset/shelf@2x.png")
write(shelfPNG(NSSize(width: 2320, height: 720)), "Top Shelf Image Wide.imageset/shelf-wide@1x.png")
write(shelfPNG(NSSize(width: 4640, height: 1440)), "Top Shelf Image Wide.imageset/shelf-wide@2x.png")