diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..41d86cd --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "filename": "back@1x.png", + "idiom": "tv", + "scale": "1x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/back@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/back@1x.png new file mode 100644 index 0000000..1cd0ede Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/back@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..056eaa1 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "filename": "circle1@1x.png", + "idiom": "tv", + "scale": "1x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@1x.png new file mode 100644 index 0000000..1341ad8 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle1.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..a371961 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "filename": "circle2@1x.png", + "idiom": "tv", + "scale": "1x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@1x.png new file mode 100644 index 0000000..6dd18a6 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Circle2.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 0000000..f5d24a4 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,20 @@ +{ + "layers": [ + { + "filename": "Front.imagestacklayer" + }, + { + "filename": "Circle2.imagestacklayer" + }, + { + "filename": "Circle1.imagestacklayer" + }, + { + "filename": "Back.imagestacklayer" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..1d91ee8 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "filename": "front@1x.png", + "idiom": "tv", + "scale": "1x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/front@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/front@1x.png new file mode 100644 index 0000000..1212563 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/front@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..90d2f7f --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back@1x.png new file mode 100644 index 0000000..03f6b36 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back@2x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back@2x.png new file mode 100644 index 0000000..3d9f150 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back@2x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..f707323 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@1x.png new file mode 100644 index 0000000..344b744 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@2x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@2x.png new file mode 100644 index 0000000..7a08342 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Content.imageset/circle1@2x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle1.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..c54d601 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@1x.png new file mode 100644 index 0000000..de45331 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@2x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@2x.png new file mode 100644 index 0000000..b03febb Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Content.imageset/circle2@2x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Circle2.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 0000000..f5d24a4 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,20 @@ +{ + "layers": [ + { + "filename": "Front.imagestacklayer" + }, + { + "filename": "Circle2.imagestacklayer" + }, + { + "filename": "Circle1.imagestacklayer" + }, + { + "filename": "Back.imagestacklayer" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..c88b70a --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@1x.png new file mode 100644 index 0000000..6422b4f Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@2x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@2x.png new file mode 100644 index 0000000..e7032b8 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@2x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json new file mode 100644 index 0000000..321d0c4 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 0000000..b253474 --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/shelf-wide@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/shelf-wide@1x.png new file mode 100644 index 0000000..2e40821 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/shelf-wide@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/shelf-wide@2x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/shelf-wide@2x.png new file mode 100644 index 0000000..54b2665 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/shelf-wide@2x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 0000000..7ad290d --- /dev/null +++ b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/shelf@1x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/shelf@1x.png new file mode 100644 index 0000000..843c188 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/shelf@1x.png differ diff --git a/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/shelf@2x.png b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/shelf@2x.png new file mode 100644 index 0000000..0128544 Binary files /dev/null and b/clients/apple/App/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/shelf@2x.png differ diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index aa4a389..06c126a 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -24,6 +24,9 @@ let package = Package( ] ), // 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"]), .testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]), ] diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 13f9d69..4598429 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; }; BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; }; CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; }; + DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -53,6 +54,7 @@ buildActionMask = 2147483647; files = ( CC0000000000000000000005 /* PunktfunkKit in Frameworks */, + DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -148,6 +150,7 @@ name = "Punktfunk-tvOS"; packageProductDependencies = ( CC0000000000000000000006 /* PunktfunkKit */, + DD0000000000000000000002 /* SwiftUINavigationTransitions */, ); productName = "Punktfunk-tvOS"; productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */; @@ -177,6 +180,7 @@ mainGroup = AA0000000000000000000007; packageReferences = ( AA000000000000000000000F /* XCLocalSwiftPackageReference "." */, + DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */, ); preferredProjectObjectVersion = 77; productRefGroup = AA0000000000000000000008 /* Products */; @@ -487,8 +491,10 @@ CC0000000000000000000012 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F4H37KF6WC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -513,8 +519,10 @@ CC0000000000000000000013 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F4H37KF6WC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -539,15 +547,6 @@ /* End XCBuildConfiguration 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" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -575,6 +574,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC0000000000000000000012 /* Debug */, + CC0000000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -584,6 +592,17 @@ }; /* 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 */ AA0000000000000000000006 /* PunktfunkKit */ = { isa = XCSwiftPackageProductDependency; @@ -597,6 +616,11 @@ isa = XCSwiftPackageProductDependency; productName = PunktfunkKit; }; + DD0000000000000000000002 /* SwiftUINavigationTransitions */ = { + isa = XCSwiftPackageProductDependency; + package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */; + productName = SwiftUINavigationTransitions; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AA000000000000000000000D /* Project object */; diff --git a/clients/apple/Punktfunk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/clients/apple/Punktfunk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..be619c5 --- /dev/null +++ b/clients/apple/Punktfunk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/clients/apple/README.md b/clients/apple/README.md index 6e859e7..3525a89 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -146,7 +146,8 @@ signing, bundle id `io.unom.punktfunk`. Notes: 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 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 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: diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index 16fe4a3..c8619bb 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -8,30 +8,86 @@ struct AddHostSheet: View { @State private var name = "" @State private var address = "" @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 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) { Form { TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) TextField("Address", text: $address, prompt: Text("IP or hostname")) 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 { Button("Cancel", role: .cancel) { dismiss() } #if !os(tvOS) .keyboardShortcut(.cancelAction) #endif Spacer() - Button("Add Host") { - onAdd(StoredHost( - name: name.trimmingCharacters(in: .whitespaces), - address: address.trimmingCharacters(in: .whitespaces), - port: UInt16(clamping: port))) - dismiss() - } + Button("Add Host") { add() } .buttonStyle(.borderedProminent) #if !os(tvOS) .keyboardShortcut(.defaultAction) @@ -47,5 +103,14 @@ struct AddHostSheet: View { .frame(width: 380) .fixedSize(horizontal: false, vertical: true) #endif + #endif + } + + private func add() { + onAdd(StoredHost( + name: name.trimmingCharacters(in: .whitespaces), + address: address.trimmingCharacters(in: .whitespaces), + port: UInt16(clamping: port))) + dismiss() } } diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 3adabc6..1a853ad 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -13,6 +13,9 @@ import AppKit #endif import PunktfunkKit import SwiftUI +#if os(tvOS) +import SwiftUINavigationTransitions +#endif struct ContentView: View { @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 // (the "Pair with PIN instead" path disconnects first — the host's accept loop // is sequential, a pairing connection would queue behind the live session). + #if !os(tvOS) .sheet(item: $pairingTarget) { host in PairSheet(host: host) { fingerprint in // Backstop against a stale ceremony surfacing after dismissal (PairSheet @@ -66,6 +70,7 @@ struct ContentView: View { connect(pinned) } } + #endif } private var sessionView: some View { @@ -110,18 +115,54 @@ struct ContentView: View { emptyState } else { ScrollView { - LazyVGrid(columns: gridColumns, spacing: 16) { + LazyVGrid(columns: gridColumns, spacing: gridSpacing) { ForEach(store.hosts) { host in hostCard(host) } } .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") + #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 { - #if !os(macOS) + #if os(iOS) // Adjacent trailing items share one glass pill (the system default). ToolbarItem(placement: .topBarTrailing) { settingsButton } ToolbarItem(placement: .topBarTrailing) { addHostButton } @@ -138,14 +179,24 @@ struct ContentView: View { } #endif } + #endif } #if os(macOS) .frame(minWidth: 480, minHeight: 360) #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) { AddHostSheet { store.add($0) } } - #if !os(macOS) + #if os(iOS) .sheet(isPresented: $showSettings) { NavigationStack { SettingsView() @@ -156,6 +207,7 @@ struct ContentView: View { } } #endif + #endif .alert( "Connection failed", isPresented: Binding( @@ -175,11 +227,21 @@ struct ContentView: View { private var gridColumns: [GridItem] { #if os(macOS) [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)] + #elseif os(tvOS) + [GridItem(.adaptive(minimum: 320), spacing: 48)] #else [GridItem(.adaptive(minimum: 280), spacing: 16)] #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 { Button { showAddHost = true @@ -209,6 +271,9 @@ struct ContentView: View { #if os(iOS) .controlSize(.large) #endif + #if os(tvOS) + Button("Settings") { showSettings = true } + #endif } } @@ -265,6 +330,9 @@ struct ContentView: View { .frame(maxWidth: .infinity) .padding(.vertical, cardPadding) .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)) .overlay { if host.id == mostRecentHostID { @@ -272,6 +340,7 @@ struct ContentView: View { .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5) } } + #endif } #if os(tvOS) .buttonStyle(.card) diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index 3a37dd0..e180f75 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -1,6 +1,6 @@ -// PIN pairing sheet. The host, started with --allow-pairing (or --require-pairing), -// prints a short PIN at startup ("PAIRING ARMED — enter this PIN on the client to -// pair"); the user types it here. The ceremony is SPAKE2, so a wrong PIN buys an +// PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000 → +// Pairing; also printed in the host's log when armed via --allow-pairing); the user +// 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 // 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 @@ -33,12 +33,76 @@ struct PairSheet: View { @State private var busy = false @State private var errorText: String? @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 { + #if os(tvOS) + VStack(spacing: 24) { + Text("The PIN is shown in the host's web console " + + "(http://: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) { Form { 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)) #if os(iOS) .keyboardType(.numberPad) @@ -46,12 +110,15 @@ struct PairSheet: View { TextField( "Client name", text: $clientName, prompt: Text("How the host lists this Mac")) + #if os(tvOS) + .labelsHidden() // prefilled → tvOS floats the label off-center + #endif } header: { Label("Pair with \(host.displayName)", systemImage: "lock.shield") .foregroundStyle(.tint) } footer: { - Text("The host prints the PIN when pairing is armed " - + "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). " + Text("The PIN is shown in the host's web console " + + "(http://:3000 → Pairing). " + "Pairing verifies both sides at once — no fingerprint " + "comparison needed.") .font(.caption) @@ -65,7 +132,9 @@ struct PairSheet: View { } } } - .formStyle(.grouped) + #if !os(tvOS) + .formStyle(.grouped) + #endif HStack { Button("Cancel", role: .cancel) { token.cancelled = true @@ -98,6 +167,7 @@ struct PairSheet: View { #endif .interactiveDismissDisabled(busy) .onDisappear { token.cancelled = true } // any other dismissal path + #endif } private func runCeremony() { @@ -126,16 +196,16 @@ struct PairSheet: View { onPaired(fingerprint) dismiss() case .failure(PunktfunkClientError.wrongPIN): - errorText = "Wrong PIN — check the host's \u{201C}PAIRING ARMED\u{201D} " - + "line and try again." + errorText = "Wrong PIN — check the host's web console (port 3000) " + + "and try again." case .failure(is ClientIdentityStore.IdentityError): errorText = "Can't store this Mac's identity in the Keychain, so the " + "pairing would not survive a relaunch. Unlock the login " + "keychain and try again." case .failure: - errorText = "Pairing failed. Is the host reachable, armed with " - + "--allow-pairing, and not mid-session? Retries are rate-limited " - + "to one per 2 seconds." + errorText = "Pairing failed. Is the host reachable, pairing armed " + + "(web console → Pairing), and not mid-session? Retries are " + + "rate-limited to one per 2 seconds." } } } diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 06f3201..bd5157f 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -9,6 +9,7 @@ import PunktfunkKit import SwiftUI struct SettingsView: View { + @Environment(\.dismiss) private var dismiss @AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 @@ -22,6 +23,76 @@ struct SettingsView: View { #endif 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 { + 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 { Section { HStack { diff --git a/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift b/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift new file mode 100644 index 0000000..575e199 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift @@ -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: 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: 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 diff --git a/scripts/render-tvos-icon.swift b/scripts/render-tvos-icon.swift new file mode 100644 index 0000000..32f552b --- /dev/null +++ b/scripts/render-tvos-icon.swift @@ -0,0 +1,91 @@ +// Usage: swift scripts/render-tvos-icon.swift +// "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")