Add tvOS
Seto Elkahfi committed
May 27, 2026 at 22:59 UTC
d249bedfd151082d64828e5c49944ca1dd823f18
72 files changed
+3912
-140
.gitignore
+1
@@ -58,6 +58,7 @@ venv/
58
*.local
59
*.xcconfig.local
60
Configs/Signing.local.xcconfig
61
+Configs/Secrets.local.xcconfig
62
*.secret
63
Secrets/
64
secrets/
Configs/Debug.local.xcconfig.example
new
+8
@@ -0,0 +1,8 @@
1
+// Copy this file to Configs/Debug.local.xcconfig (git-ignored).
2
+// Set your Mac's LAN IP so a real device can reach the local onde-web server.
3
+//
4
+// Find your IP: run `ipconfig getifaddr en0` in Terminal.
5
+// Start onde-web with: pnpm dev -- --hostname 0.0.0.0
6
+
7
+ONDE_WEB_DEV_HOST = 192.168.x.x
8
+// ONDE_WEB_DEV_PORT = 3000
Configs/Debug.xcconfig
new
+16
@@ -0,0 +1,16 @@
1
+// Debug build settings.
2
+//
3
+// ONDE_WEB_DEV_HOST defaults to localhost, which works for the iOS Simulator.
4
+//
5
+// For a real device on your local network, create Configs/Debug.local.xcconfig
6
+// (git-ignored) and set your Mac's LAN IP there:
7
+//
8
+// ONDE_WEB_DEV_HOST = 192.168.x.x
9
+//
10
+// Run `ipconfig getifaddr en0` in Terminal to find your IP.
11
+
12
+#include? "Debug.local.xcconfig"
13
+#include "Signing.xcconfig"
14
+
15
+ONDE_WEB_DEV_HOST = $(ONDE_WEB_DEV_HOST:default=localhost)
16
+ONDE_WEB_DEV_PORT = $(ONDE_WEB_DEV_PORT:default=3000)
Configs/Release.xcconfig
new
+4
@@ -0,0 +1,4 @@
1
+// Release build settings.
2
+// Dev host vars are intentionally absent — production URL is used exclusively.
3
+
4
+#include "Signing.xcconfig"
Configs/Secrets.local.xcconfig.example
new
+24
@@ -0,0 +1,24 @@
1
+// Copy this file to Configs/Secrets.local.xcconfig (git-ignored)
2
+// and fill in your Onde Cloud credentials for each platform.
3
+
4
+// iOS
5
+ONDE_APP_ID[sdk=iphoneos*] = your-ios-app-id
6
+ONDE_APP_SECRET[sdk=iphoneos*] = your-ios-app-secret
7
+ONDE_APP_ID[sdk=iphonesimulator*] = your-ios-app-id
8
+ONDE_APP_SECRET[sdk=iphonesimulator*] = your-ios-app-secret
9
+
10
+// tvOS
11
+ONDE_APP_ID[sdk=appletvos*] = your-tvos-app-id
12
+ONDE_APP_SECRET[sdk=appletvos*] = your-tvos-app-secret
13
+ONDE_APP_ID[sdk=appletvsimulator*] = your-tvos-app-id
14
+ONDE_APP_SECRET[sdk=appletvsimulator*] = your-tvos-app-secret
15
+
16
+// macOS
17
+ONDE_APP_ID[sdk=macosx*] = your-macos-app-id
18
+ONDE_APP_SECRET[sdk=macosx*] = your-macos-app-secret
19
+
20
+// visionOS
21
+ONDE_APP_ID[sdk=xros*] = your-visionos-app-id
22
+ONDE_APP_SECRET[sdk=xros*] = your-visionos-app-secret
23
+ONDE_APP_ID[sdk=xrsimulator*] = your-visionos-app-id
24
+ONDE_APP_SECRET[sdk=xrsimulator*] = your-visionos-app-secret
Configs/Secrets.xcconfig
new
+13
@@ -0,0 +1,13 @@
1
+// Onde Cloud credentials.
2
+//
3
+// Each Apple platform has its own Onde app with separate credentials.
4
+// Create Configs/Secrets.local.xcconfig (git-ignored) with SDK-conditional
5
+// values — see Secrets.local.xcconfig.example for the format.
6
+
7
+#include? "Secrets.local.xcconfig"
8
+
9
+// Self-referencing defaults: if Secrets.local.xcconfig provides a value for
10
+// the current SDK, it wins; otherwise these resolve to empty strings and
11
+// OndeGuideEngine shows a configuration error at runtime.
12
+ONDE_APP_ID = $(ONDE_APP_ID)
13
+ONDE_APP_SECRET = $(ONDE_APP_SECRET)
Configs/Signing.xcconfig
+2
@@ -10,3 +10,5 @@
10
#include? "Signing.local.xcconfig"
11
12
CODE_SIGN_STYLE = Automatic
13
+
14
+#include "Secrets.xcconfig"
Klepon.xcodeproj/project.pbxproj
+271
-31
@@ -18,6 +18,7 @@
18
0CE2632DF6FB02163D55BB65 /* Klepon Watch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 9E76510CC840CE068B0FC381 /* Klepon Watch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
19
0D685E35EE50C926DC6FBE4C /* KleponChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4F3C8688072191AA16C6 /* KleponChip.swift */; };
20
0E350E68B43067698FE3B05C /* KleponChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4F3C8688072191AA16C6 /* KleponChip.swift */; };
21
+ 0E5D1E78C77AD38DD7A3D7F6 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFCDF98AD60ED73A388B23D /* AccountStore.swift */; };
22
0E69E79C5FADF1633FE3D63F /* KleponColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C675ABBBA25BBA25A8DE29BE /* KleponColor.swift */; };
23
14B7CE6E6A0EB12B709F7B55 /* KleponTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD520C870387E82E9A1533C2 /* KleponTypography.swift */; };
24
14C796CE6777B6013910831C /* SavedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA2E425D5454602F78D628 /* SavedView.swift */; };
@@ -35,8 +36,10 @@
36
215C4D8A2B2ABF2C3C11C163 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8870F09A11EA6D3F0108DA4D /* SystemConfiguration.framework */; };
37
23E22EC56BE48A20BD1229F7 /* AnswerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02B0EA4B8BA09604D62A62B /* AnswerCard.swift */; };
38
26FA5D01FED96591E78A2293 /* AskSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC6E7DC56DD8E1459EFC892 /* AskSheetView.swift */; };
39
+ 2A3F2C3AF20192B1FCE71B56 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFECF1D8C873FA535DE2FD /* AccountView.swift */; };
40
2AB57009245E1BABD1156AFA /* GuideDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7541983FF7CE190C0663294D /* GuideDetailView.swift */; };
41
2AD05B0B7ED109134F013862 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E7B5E3D9A1906C2FED3032 /* EmptyStateView.swift */; };
42
+ 2C576E80607B50FC80C97A08 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C62410511A5229D61B0A8E1 /* SceneDelegate.swift */; };
43
2F3047F5078C4526133CB848 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13418896CA28250CEEE7592E /* OnboardingView.swift */; };
44
2F74C3B8D4A1A38DEF50E39B /* AskSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC6E7DC56DD8E1459EFC892 /* AskSheetView.swift */; };
45
3349FB46691C944112401D6E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E152344B39D1D77F1B3943 /* SettingsView.swift */; };
@@ -49,21 +52,25 @@
52
37BD5B2A275426143819B70D /* OndeGuideEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0D53A5F9D5897E15506C20 /* OndeGuideEngine.swift */; };
53
387EE903EFA0D820C646B4DB /* GuideArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F2850226B4B05FF758C1F93 /* GuideArtworkView.swift */; };
54
3A6F67743964C4D04893C321 /* RecentlyViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45070309D2C7058BAA44CD2 /* RecentlyViewedStore.swift */; };
55
+ 3C39523ADE9453A7043216B8 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFCDF98AD60ED73A388B23D /* AccountStore.swift */; };
56
3C4C5EEBF766D6E8A5D240DA /* PlatformViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51D6F97615398D511673B98 /* PlatformViewSupport.swift */; };
57
3EB900CB86D2B32F8DAB434D /* GuideAnswerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3719031080B59898133810F7 /* GuideAnswerService.swift */; };
58
3F999E04650931C2019BDA17 /* KleponColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C675ABBBA25BBA25A8DE29BE /* KleponColor.swift */; };
59
41F8E173B82809AF87E1B297 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13418896CA28250CEEE7592E /* OnboardingView.swift */; };
60
43ABAA3C08695B7C8FEE7324 /* KleponMetadataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C50BFBE2EF5F37941590 /* KleponMetadataRow.swift */; };
61
4609C70D9FB74C55B9DBE832 /* KleponActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DD909BB2EF68103CB46EDA /* KleponActionButton.swift */; };
62
+ 46D9C39408990F1C332EA957 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFCDF98AD60ED73A388B23D /* AccountStore.swift */; };
63
478FFEBDAA7729189B429A6D /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E886BAB66E828950DBED52 /* SearchView.swift */; };
64
47B632AF6BF3E25AF628CC01 /* Onde in Frameworks */ = {isa = PBXBuildFile; productRef = 35BDA28E3F43D5441C5FE932 /* Onde */; };
65
4B6E0CF8EAB6FF1D0160E3E6 /* KleponColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C675ABBBA25BBA25A8DE29BE /* KleponColor.swift */; };
66
4D63E231AC9630B2C225DF22 /* WatchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBB4F5D4C98D036AC52E193 /* WatchHomeView.swift */; };
67
+ 53D759A429886E0FD8E5E9BF /* KleponTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0AF6507157DAF401A9BC40 /* KleponTabBarController.swift */; };
68
5692BBCE950885C5C1193DDF /* collections.json in Resources */ = {isa = PBXBuildFile; fileRef = 1FDDB0F88FF430ABD679AD89 /* collections.json */; };
69
5808C4539EBB98D822BDE99F /* KleponActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DD909BB2EF68103CB46EDA /* KleponActionButton.swift */; };
70
587F0BB61966B2BB048013DE /* KleponChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4F3C8688072191AA16C6 /* KleponChip.swift */; };
71
5C3590F8B1A8255C5E175A03 /* PlatformViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51D6F97615398D511673B98 /* PlatformViewSupport.swift */; };
72
5D3BDDCB5CA8A8DC32E8ABEA /* Onde in Frameworks */ = {isa = PBXBuildFile; productRef = CECDF5959E6652B49DCF1E18 /* Onde */; };
73
+ 609532817F88D0D6F1E3CB16 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFECF1D8C873FA535DE2FD /* AccountView.swift */; };
74
6117CD728CC7FF8D6EB0A8C8 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13418896CA28250CEEE7592E /* OnboardingView.swift */; };
75
67368394088AA3045EBA14A5 /* OndeEnvironmentBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B499D3BF7AC80D31907D87F /* OndeEnvironmentBootstrap.swift */; };
76
68479892D13825C6C9977DF9 /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC0C4C22A5134DB058E41F4 /* FavoritesStore.swift */; };
@@ -76,6 +83,7 @@
83
78142FDCFE7EF00A3D81A067 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E152344B39D1D77F1B3943 /* SettingsView.swift */; };
84
7A9B7E0858E7095E18EC7AC6 /* KleponApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDE2A1E73EB15970A17A894 /* KleponApp.swift */; };
85
7C90532B70C1C4840E49A92B /* GuideDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7541983FF7CE190C0663294D /* GuideDetailView.swift */; };
86
+ 7E6755E542F15B6921C21604 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFCDF98AD60ED73A388B23D /* AccountStore.swift */; };
87
7F1E744AAE60BF1FB132D66C /* KleponCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868C58098969278A542893F3 /* KleponCard.swift */; };
88
7F726FA99DBDC0155E99E098 /* SectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC438ECC335175B18778C682 /* SectionHeader.swift */; };
89
8189E2DF13801C2B7162AD45 /* AnswerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02B0EA4B8BA09604D62A62B /* AnswerCard.swift */; };
@@ -89,17 +97,20 @@
97
88984B6A8AA504AD200A7F39 /* collections.json in Resources */ = {isa = PBXBuildFile; fileRef = 1FDDB0F88FF430ABD679AD89 /* collections.json */; };
98
889CBF2AF84AABF8012EE737 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF79D8DD27B9CA485140C934 /* Assets.xcassets */; };
99
895B5369E9CF1E1B26CDF40E /* KleponTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD520C870387E82E9A1533C2 /* KleponTypography.swift */; };
100
+ 89F1A80A8FDB8E8705878210 /* Klepon TV Top Shelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = ED4132D117B29BD22A1FB6C2 /* Klepon TV Top Shelf.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
101
8C6D6EC32870A45E021BF3EB /* KleponMetadataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C50BFBE2EF5F37941590 /* KleponMetadataRow.swift */; };
102
8EBCC9FA074785FDD82B02E5 /* KleponApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDE2A1E73EB15970A17A894 /* KleponApp.swift */; };
103
906E12C00728B15B676A23DB /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AF291E9C2E66209377138E6 /* AppState.swift */; };
104
910A84B3F28B3F79871FF60E /* SectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC438ECC335175B18778C682 /* SectionHeader.swift */; };
105
926AB007EBCD5315EE77E2A6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF79D8DD27B9CA485140C934 /* Assets.xcassets */; };
97
- 935A2F7D98CCC0DEA5EA2782 /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C82874C90A5FAA32D1B52EB7 /* AppRootView.swift */; };
106
93E938AA978ABF5665737887 /* GuideEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A0935AE4B398B281758834 /* GuideEntry.swift */; };
107
955D039F4B5D14FCA4E7F2B2 /* GuideArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F2850226B4B05FF758C1F93 /* GuideArtworkView.swift */; };
108
+ 972FD85EF2549D2C82EA35A1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8458C69807F335C0F7E97EE /* AppDelegate.swift */; };
109
99071E0CB9F44857C0BFC8C9 /* SavedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA2E425D5454602F78D628 /* SavedView.swift */; };
110
995C5FDC790E09B87CB5F1C4 /* KleponTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD520C870387E82E9A1533C2 /* KleponTypography.swift */; };
111
997D63C43B61CB342C586FB8 /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4545DC84863788D55B94D31A /* DiscoverView.swift */; };
112
+ 99C4E0BC590F52400F28A5A4 /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3E2497612D02F0279A791F /* DebugMenuView.swift */; };
113
+ 9AE51D59952308FED1DD538B /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3E2497612D02F0279A791F /* DebugMenuView.swift */; };
114
9BF8C40A4C12038A9DD2D0D7 /* collections.json in Resources */ = {isa = PBXBuildFile; fileRef = 1FDDB0F88FF430ABD679AD89 /* collections.json */; };
115
9E16F5C37F30B58441780260 /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4545DC84863788D55B94D31A /* DiscoverView.swift */; };
116
9E42E7DE70F8582B66C895F3 /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6235AD6792F25476E2A508F0 /* SearchService.swift */; };
@@ -116,14 +127,18 @@
127
A69F302A8F11969D9CD197EE /* guide_entries.json in Resources */ = {isa = PBXBuildFile; fileRef = 248F359109A08A60F792C42D /* guide_entries.json */; };
128
A7001D58E0F2B1BD5F52FF8A /* Onde in Frameworks */ = {isa = PBXBuildFile; productRef = 855EE8DF62A7820E77C05D1A /* Onde */; };
129
A76C56EF8CFB6D723B55EE74 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E886BAB66E828950DBED52 /* SearchView.swift */; };
130
+ A81DB2CF33EF6E8730010E87 /* topshelf-klepon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8C4826639AED1295CAA284B0 /* topshelf-klepon.png */; };
131
B1F1D1A453150D3BBB0146EF /* RecentlyViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45070309D2C7058BAA44CD2 /* RecentlyViewedStore.swift */; };
132
B34377720179454F9B9184C5 /* OndeGuideEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0D53A5F9D5897E15506C20 /* OndeGuideEngine.swift */; };
133
+ B395C990F4E3A61631BBE48D /* topshelf-street-food.png in Resources */ = {isa = PBXBuildFile; fileRef = 539B3F0A03FFFDCF3DB41241 /* topshelf-street-food.png */; };
134
B4467AA7214B4990D2FC19DA /* KleponCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868C58098969278A542893F3 /* KleponCard.swift */; };
135
+ B5A8D8C810B7D57B637DA94C /* topshelf-rendang.png in Resources */ = {isa = PBXBuildFile; fileRef = 4CE4B295D6135D280E3DD042 /* topshelf-rendang.png */; };
136
B5B076B218DF86269DD7F80D /* GuideArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F2850226B4B05FF758C1F93 /* GuideArtworkView.swift */; };
137
B768150B02B19417B81FE651 /* guide_entries.json in Resources */ = {isa = PBXBuildFile; fileRef = 248F359109A08A60F792C42D /* guide_entries.json */; };
138
B7DE9EC8BE998E6719C88E83 /* guide_entries.json in Resources */ = {isa = PBXBuildFile; fileRef = 248F359109A08A60F792C42D /* guide_entries.json */; };
139
B97D6D5885E0B6CFB358B1BC /* KleponActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DD909BB2EF68103CB46EDA /* KleponActionButton.swift */; };
140
BB70FAD944544125AF261F7C /* SavedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA2E425D5454602F78D628 /* SavedView.swift */; };
141
+ BC40004C4FFBC035CA9DDD6F /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3E2497612D02F0279A791F /* DebugMenuView.swift */; };
142
BC8102C70075D3DEDE537CDD /* KleponActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DD909BB2EF68103CB46EDA /* KleponActionButton.swift */; };
143
C15131992039B3AC7DB74DAB /* testGuideArtworkView.guide-artwork.png in Resources */ = {isa = PBXBuildFile; fileRef = 9D6EFA021D4524508DA8E78C /* testGuideArtworkView.guide-artwork.png */; };
144
C1DF8DB1F063D1C5258602D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF79D8DD27B9CA485140C934 /* Assets.xcassets */; };
@@ -134,19 +149,22 @@
149
C709C82E7F0BCF398BCD9C2F /* ContentRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2891A1AFBB59928FC5B1E66 /* ContentRepository.swift */; };
150
C88013421E507AEDCB41BE96 /* OndeGuideEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0D53A5F9D5897E15506C20 /* OndeGuideEngine.swift */; };
151
C8ECF8664277103582D96B45 /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC0C4C22A5134DB058E41F4 /* FavoritesStore.swift */; };
152
+ CA34777FEEC140D938934C01 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFECF1D8C873FA535DE2FD /* AccountView.swift */; };
153
+ CA9472623EBA8F24348A9F05 /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3E2497612D02F0279A791F /* DebugMenuView.swift */; };
154
+ CAF237DB042D25046A64CD4D /* ContentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998AA7A251DE3AD07A88B1E /* ContentProvider.swift */; };
155
CE5C9946E68E1F7A781F1628 /* AnswerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02B0EA4B8BA09604D62A62B /* AnswerCard.swift */; };
156
CE6824D3D8261BF0463F39D8 /* KleponMetadataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C50BFBE2EF5F37941590 /* KleponMetadataRow.swift */; };
157
D2D49509E9CB5CF2BA4EBF6D /* GuideAnswerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3719031080B59898133810F7 /* GuideAnswerService.swift */; };
158
D6D8A42930EA9A0EA6622108 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AF291E9C2E66209377138E6 /* AppState.swift */; };
159
D99AAE1CDE51139840A52607 /* KleponMetadataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C50BFBE2EF5F37941590 /* KleponMetadataRow.swift */; };
160
DA915BA8B2F732E7D62CF936 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E7B5E3D9A1906C2FED3032 /* EmptyStateView.swift */; };
143
- DBD7CE5D9AB42EA74CC9F7A1 /* KleponApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDE2A1E73EB15970A17A894 /* KleponApp.swift */; };
161
DDD3529FAEA0D105EBD5C168 /* KleponTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD520C870387E82E9A1533C2 /* KleponTypography.swift */; };
162
E17595636392EDF3D6EEE895 /* OndeEnvironmentBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B499D3BF7AC80D31907D87F /* OndeEnvironmentBootstrap.swift */; };
163
E25DE24C663A9579D28F21D4 /* GuideEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A0935AE4B398B281758834 /* GuideEntry.swift */; };
164
E45F257E3D35DC565B21B299 /* KleponColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C675ABBBA25BBA25A8DE29BE /* KleponColor.swift */; };
165
E599CCDB1FE5331C4A1EB6C6 /* KleponSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759E92BBAF958F5882F17C37 /* KleponSnapshotTests.swift */; };
166
E87F8A08D6922557A5DBD198 /* testOnboardingView_defaultState.default.png in Resources */ = {isa = PBXBuildFile; fileRef = 414FD1A8945B820DCC4ED192 /* testOnboardingView_defaultState.default.png */; };
167
+ EAD7B26527734629EC834D13 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFECF1D8C873FA535DE2FD /* AccountView.swift */; };
168
ECDD606D8AFA776765AA9A31 /* SectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC438ECC335175B18778C682 /* SectionHeader.swift */; };
169
EDD126593FECA8D9EB7DBC4A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E7B5E3D9A1906C2FED3032 /* EmptyStateView.swift */; };
170
EE2103530984735318E469FD /* AskSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC6E7DC56DD8E1459EFC892 /* AskSheetView.swift */; };
@@ -158,6 +176,7 @@
176
F64259FB47704FC3E2F15FF7 /* RecentlyViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45070309D2C7058BAA44CD2 /* RecentlyViewedStore.swift */; };
177
F87EF3C291B7D3FB9BE8E43A /* ContentRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2891A1AFBB59928FC5B1E66 /* ContentRepository.swift */; };
178
F88972525510344E63D2983C /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C82874C90A5FAA32D1B52EB7 /* AppRootView.swift */; };
179
+ F8C02BC1E76C4DCAC7C1F237 /* topshelf-sambal.png in Resources */ = {isa = PBXBuildFile; fileRef = 8247F5A888E0097D2BBCED1B /* topshelf-sambal.png */; };
180
F9983D842EB95B43620CA52B /* GuideDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7541983FF7CE190C0663294D /* GuideDetailView.swift */; };
181
F9D6FC75A11BE98BD28BBDC2 /* RecentSearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643D52BCEDF66CC8FE8A1234 /* RecentSearchStore.swift */; };
182
FA423A76BE098A9911044246 /* AskSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC6E7DC56DD8E1459EFC892 /* AskSheetView.swift */; };
@@ -180,9 +199,27 @@
199
remoteGlobalIDString = E8CEBF52617721AEE450A7CF;
200
remoteInfo = "Klepon Watch";
201
};
202
+ F5DCD3DD3B0670276438A4D3 /* PBXContainerItemProxy */ = {
203
+ isa = PBXContainerItemProxy;
204
+ containerPortal = 05A5707BDA883282DEB132FF /* Project object */;
205
+ proxyType = 1;
206
+ remoteGlobalIDString = 62904071A31F700A13D6F432;
207
+ remoteInfo = "Klepon TV Top Shelf";
208
+ };
209
/* End PBXContainerItemProxy section */
210
211
/* Begin PBXCopyFilesBuildPhase section */
212
+ 85563DDB067EE8F6CA513DC2 /* Embed Foundation Extensions */ = {
213
+ isa = PBXCopyFilesBuildPhase;
214
+ buildActionMask = 2147483647;
215
+ dstPath = "";
216
+ dstSubfolderSpec = 13;
217
+ files = (
218
+ 89F1A80A8FDB8E8705878210 /* Klepon TV Top Shelf.appex in Embed Foundation Extensions */,
219
+ );
220
+ name = "Embed Foundation Extensions";
221
+ runOnlyForDeploymentPostprocessing = 0;
222
+ };
223
F3457485B5B2DC81D4FAA704 /* Embed Watch Content */ = {
224
isa = PBXCopyFilesBuildPhase;
225
buildActionMask = 2147483647;
@@ -199,6 +236,7 @@
236
/* Begin PBXFileReference section */
237
054656155630B064F785B0D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
238
0BC0C4C22A5134DB058E41F4 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = "<group>"; };
239
+ 0FFFECF1D8C873FA535DE2FD /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
240
13418896CA28250CEEE7592E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
241
13B09D700AA047E07E91F6A2 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = "<group>"; };
242
1FDDB0F88FF430ABD679AD89 /* collections.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = collections.json; sourceTree = "<group>"; };
@@ -209,47 +247,60 @@
247
414FD1A8945B820DCC4ED192 /* testOnboardingView_defaultState.default.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = testOnboardingView_defaultState.default.png; sourceTree = "<group>"; };
248
42E152344B39D1D77F1B3943 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
249
4545DC84863788D55B94D31A /* DiscoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverView.swift; sourceTree = "<group>"; };
250
+ 4A0AF6507157DAF401A9BC40 /* KleponTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponTabBarController.swift; sourceTree = "<group>"; };
251
4C77AF5EB4B17D087FA522F6 /* WatchContentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContentLoader.swift; sourceTree = "<group>"; };
252
+ 4CE4B295D6135D280E3DD042 /* topshelf-rendang.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "topshelf-rendang.png"; sourceTree = "<group>"; };
253
52DD909BB2EF68103CB46EDA /* KleponActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponActionButton.swift; sourceTree = "<group>"; };
254
+ 539B3F0A03FFFDCF3DB41241 /* topshelf-street-food.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "topshelf-street-food.png"; sourceTree = "<group>"; };
255
5AF291E9C2E66209377138E6 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
215
- 5DE91827A635C0B0B72690EC /* Klepon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Klepon.app; sourceTree = BUILT_PRODUCTS_DIR; };
256
+ 5AFCDF98AD60ED73A388B23D /* AccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStore.swift; sourceTree = "<group>"; };
257
+ 5DE91827A635C0B0B72690EC /* Klepon Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Klepon Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
258
6235AD6792F25476E2A508F0 /* SearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchService.swift; sourceTree = "<group>"; };
259
643D52BCEDF66CC8FE8A1234 /* RecentSearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchStore.swift; sourceTree = "<group>"; };
260
67FAA69C42F1845ECB79AC16 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
261
6B67BCC5A459288FB240C6E7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
262
6EFBF98118F066D3C7FA14B2 /* KleponWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponWatchApp.swift; sourceTree = "<group>"; };
263
7541983FF7CE190C0663294D /* GuideDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideDetailView.swift; sourceTree = "<group>"; };
222
- 754A7FAB8C92112B8B6228B7 /* Klepon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Klepon.app; sourceTree = BUILT_PRODUCTS_DIR; };
264
+ 754A7FAB8C92112B8B6228B7 /* Klepon Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Klepon Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
265
759E92BBAF958F5882F17C37 /* KleponSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponSnapshotTests.swift; sourceTree = "<group>"; };
266
+ 7C62410511A5229D61B0A8E1 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
267
7F2850226B4B05FF758C1F93 /* GuideArtworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideArtworkView.swift; sourceTree = "<group>"; };
268
+ 8247F5A888E0097D2BBCED1B /* topshelf-sambal.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "topshelf-sambal.png"; sourceTree = "<group>"; };
269
85E7B5E3D9A1906C2FED3032 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
270
868C58098969278A542893F3 /* KleponCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponCard.swift; sourceTree = "<group>"; };
271
8870F09A11EA6D3F0108DA4D /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
272
+ 8C4826639AED1295CAA284B0 /* topshelf-klepon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "topshelf-klepon.png"; sourceTree = "<group>"; };
273
943670F01925DE3065B92636 /* testEmptyStateView.saved-empty-state.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "testEmptyStateView.saved-empty-state.png"; sourceTree = "<group>"; };
274
+ 95321199A19014C816398CAC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
275
98AA2E425D5454602F78D628 /* SavedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedView.swift; sourceTree = "<group>"; };
276
9D6EFA021D4524508DA8E78C /* testGuideArtworkView.guide-artwork.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "testGuideArtworkView.guide-artwork.png"; sourceTree = "<group>"; };
277
9E76510CC840CE068B0FC381 /* Klepon Watch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Klepon Watch.app"; sourceTree = BUILT_PRODUCTS_DIR; };
278
A4400AB0AB8D245325A00BA3 /* Klepon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Klepon.entitlements; sourceTree = "<group>"; };
279
A48AC0E39277DEFCE5D103CC /* KleponSnapshotTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KleponSnapshotTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
280
A7E1C50BFBE2EF5F37941590 /* KleponMetadataRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponMetadataRow.swift; sourceTree = "<group>"; };
281
+ A998AA7A251DE3AD07A88B1E /* ContentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProvider.swift; sourceTree = "<group>"; };
282
AF79D8DD27B9CA485140C934 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
283
B02B0EA4B8BA09604D62A62B /* AnswerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerCard.swift; sourceTree = "<group>"; };
284
B83C4F3C8688072191AA16C6 /* KleponChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponChip.swift; sourceTree = "<group>"; };
285
+ B868F6866D5C0497F3672F12 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
286
BF0D53A5F9D5897E15506C20 /* OndeGuideEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OndeGuideEngine.swift; sourceTree = "<group>"; };
287
C0A0935AE4B398B281758834 /* GuideEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideEntry.swift; sourceTree = "<group>"; };
288
C675ABBBA25BBA25A8DE29BE /* KleponColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponColor.swift; sourceTree = "<group>"; };
289
C82874C90A5FAA32D1B52EB7 /* AppRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRootView.swift; sourceTree = "<group>"; };
290
+ CB3E2497612D02F0279A791F /* DebugMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMenuView.swift; sourceTree = "<group>"; };
291
CD520C870387E82E9A1533C2 /* KleponTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponTypography.swift; sourceTree = "<group>"; };
243
- D53EA049112C7D59177F9878 /* Klepon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Klepon.app; sourceTree = BUILT_PRODUCTS_DIR; };
292
+ D53EA049112C7D59177F9878 /* Klepon Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Klepon Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
293
DEDE2A1E73EB15970A17A894 /* KleponApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponApp.swift; sourceTree = "<group>"; };
245
- E041DB1D0DAA58746AE6F9EA /* Klepon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Klepon.app; sourceTree = BUILT_PRODUCTS_DIR; };
294
+ E041DB1D0DAA58746AE6F9EA /* Klepon Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Klepon Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
295
E45070309D2C7058BAA44CD2 /* RecentlyViewedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyViewedStore.swift; sourceTree = "<group>"; };
296
E7E886BAB66E828950DBED52 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
297
+ ED4132D117B29BD22A1FB6C2 /* Klepon TV Top Shelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Klepon TV Top Shelf.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
298
EDBB4F5D4C98D036AC52E193 /* WatchHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeView.swift; sourceTree = "<group>"; };
299
EEC6E7DC56DD8E1459EFC892 /* AskSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskSheetView.swift; sourceTree = "<group>"; };
300
F17C0DB3BD620AD9F55B7631 /* GuideCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCollection.swift; sourceTree = "<group>"; };
301
F2891A1AFBB59928FC5B1E66 /* ContentRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentRepository.swift; sourceTree = "<group>"; };
302
F51D6F97615398D511673B98 /* PlatformViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformViewSupport.swift; sourceTree = "<group>"; };
303
+ F8458C69807F335C0F7E97EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
304
FC438ECC335175B18778C682 /* SectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeader.swift; sourceTree = "<group>"; };
305
/* End PBXFileReference section */
306
@@ -301,6 +352,8 @@
352
038E8FA398B5CB76373031FD /* Configs */ = {
353
isa = PBXGroup;
354
children = (
355
+ B868F6866D5C0497F3672F12 /* Debug.xcconfig */,
356
+ 95321199A19014C816398CAC /* Release.xcconfig */,
357
13B09D700AA047E07E91F6A2 /* Signing.xcconfig */,
358
);
359
path = Configs;
@@ -325,11 +378,12 @@
378
3F9369D339C14126AB44D720 /* Products */ = {
379
isa = PBXGroup;
380
children = (
328
- D53EA049112C7D59177F9878 /* Klepon.app */,
329
- 754A7FAB8C92112B8B6228B7 /* Klepon.app */,
330
- E041DB1D0DAA58746AE6F9EA /* Klepon.app */,
381
+ D53EA049112C7D59177F9878 /* Klepon Debug.app */,
382
+ ED4132D117B29BD22A1FB6C2 /* Klepon TV Top Shelf.appex */,
383
+ 754A7FAB8C92112B8B6228B7 /* Klepon Debug.app */,
384
+ E041DB1D0DAA58746AE6F9EA /* Klepon Debug.app */,
385
9E76510CC840CE068B0FC381 /* Klepon Watch.app */,
332
- 5DE91827A635C0B0B72690EC /* Klepon.app */,
386
+ 5DE91827A635C0B0B72690EC /* Klepon Debug.app */,
387
A48AC0E39277DEFCE5D103CC /* KleponSnapshotTests.xctest */,
388
);
389
name = Products;
@@ -460,6 +514,16 @@
514
path = Resources;
515
sourceTree = "<group>";
516
};
517
+ A17E44934E91A33AC21E2199 /* Account */ = {
518
+ isa = PBXGroup;
519
+ children = (
520
+ 5AFCDF98AD60ED73A388B23D /* AccountStore.swift */,
521
+ 0FFFECF1D8C873FA535DE2FD /* AccountView.swift */,
522
+ CB3E2497612D02F0279A791F /* DebugMenuView.swift */,
523
+ );
524
+ path = Account;
525
+ sourceTree = "<group>";
526
+ };
527
AEF2506044225CA328483416 /* Detail */ = {
528
isa = PBXGroup;
529
children = (
@@ -496,6 +560,14 @@
560
path = Data;
561
sourceTree = "<group>";
562
};
563
+ D1FEC41858C6D3DEA2C3A0F6 /* Shared */ = {
564
+ isa = PBXGroup;
565
+ children = (
566
+ A17E44934E91A33AC21E2199 /* Account */,
567
+ );
568
+ path = Shared;
569
+ sourceTree = "<group>";
570
+ };
571
D31457265A488544F9D32DE0 /* Content */ = {
572
isa = PBXGroup;
573
children = (
@@ -508,15 +580,29 @@
580
D9EBF621A6F1A1729FE26516 = {
581
isa = PBXGroup;
582
children = (
583
+ EDD52736140081AB18141C4D /* App */,
584
038E8FA398B5CB76373031FD /* Configs */,
585
EA4DFA44FE8BB01979ADB187 /* Klepon */,
586
825A58FCE091C3FE902D3131 /* KleponSnapshotTests */,
587
+ E1FCE3A358D8DD3D55C6E1DE /* KleponTVTopShelfExtension */,
588
472BF5D47BEE4AF1EC80B645 /* KleponWatch */,
589
EC85AFC077F4445CA6E5B876 /* Frameworks */,
590
3F9369D339C14126AB44D720 /* Products */,
591
);
592
sourceTree = "<group>";
593
};
594
+ E1FCE3A358D8DD3D55C6E1DE /* KleponTVTopShelfExtension */ = {
595
+ isa = PBXGroup;
596
+ children = (
597
+ A998AA7A251DE3AD07A88B1E /* ContentProvider.swift */,
598
+ 8C4826639AED1295CAA284B0 /* topshelf-klepon.png */,
599
+ 4CE4B295D6135D280E3DD042 /* topshelf-rendang.png */,
600
+ 8247F5A888E0097D2BBCED1B /* topshelf-sambal.png */,
601
+ 539B3F0A03FFFDCF3DB41241 /* topshelf-street-food.png */,
602
+ );
603
+ path = KleponTVTopShelfExtension;
604
+ sourceTree = "<group>";
605
+ };
606
EA4DFA44FE8BB01979ADB187 /* Klepon */ = {
607
isa = PBXGroup;
608
children = (
@@ -530,6 +616,7 @@
616
843428B433FCDB8678AFEF36 /* Domain */,
617
584B54256B10CB0A08CF9EBB /* Features */,
618
8FCB4D289A8BA31F1213435D /* Resources */,
619
+ D1FEC41858C6D3DEA2C3A0F6 /* Shared */,
620
);
621
path = Klepon;
622
sourceTree = "<group>";
@@ -542,6 +629,17 @@
629
name = Frameworks;
630
sourceTree = "<group>";
631
};
632
+ EDD52736140081AB18141C4D /* App */ = {
633
+ isa = PBXGroup;
634
+ children = (
635
+ F8458C69807F335C0F7E97EE /* AppDelegate.swift */,
636
+ 4A0AF6507157DAF401A9BC40 /* KleponTabBarController.swift */,
637
+ 7C62410511A5229D61B0A8E1 /* SceneDelegate.swift */,
638
+ );
639
+ name = App;
640
+ path = KleponTV/App;
641
+ sourceTree = "<group>";
642
+ };
643
/* End PBXGroup section */
644
645
/* Begin PBXNativeTarget section */
@@ -563,7 +661,7 @@
661
CECDF5959E6652B49DCF1E18 /* Onde */,
662
);
663
productName = "Klepon Vision";
566
- productReference = E041DB1D0DAA58746AE6F9EA /* Klepon.app */;
664
+ productReference = E041DB1D0DAA58746AE6F9EA /* Klepon Debug.app */;
665
productType = "com.apple.product-type.application";
666
};
667
2ADB11F6C1D51B9F9A9DB15F /* Klepon */ = {
@@ -587,9 +685,28 @@
685
855EE8DF62A7820E77C05D1A /* Onde */,
686
);
687
productName = Klepon;
590
- productReference = 5DE91827A635C0B0B72690EC /* Klepon.app */;
688
+ productReference = 5DE91827A635C0B0B72690EC /* Klepon Debug.app */;
689
productType = "com.apple.product-type.application";
690
};
691
+ 62904071A31F700A13D6F432 /* Klepon TV Top Shelf */ = {
692
+ isa = PBXNativeTarget;
693
+ buildConfigurationList = 29C34512D4ABC3CD6704E6D3 /* Build configuration list for PBXNativeTarget "Klepon TV Top Shelf" */;
694
+ buildPhases = (
695
+ 92DEEB7F16A4D2F734A3DE48 /* Sources */,
696
+ C0709ADB9349E7813934595B /* Resources */,
697
+ 029DF4CC70CC3C4774474B0D /* Update CFBundleVersion */,
698
+ );
699
+ buildRules = (
700
+ );
701
+ dependencies = (
702
+ );
703
+ name = "Klepon TV Top Shelf";
704
+ packageProductDependencies = (
705
+ );
706
+ productName = "Klepon TV Top Shelf";
707
+ productReference = ED4132D117B29BD22A1FB6C2 /* Klepon TV Top Shelf.appex */;
708
+ productType = "com.apple.product-type.app-extension";
709
+ };
710
676CA86C78E7DB801DEEAF12 /* Klepon Mac */ = {
711
isa = PBXNativeTarget;
712
buildConfigurationList = 78899F7C62A9675700D42C5A /* Build configuration list for PBXNativeTarget "Klepon Mac" */;
@@ -609,7 +726,7 @@
726
35BDA28E3F43D5441C5FE932 /* Onde */,
727
);
728
productName = "Klepon Mac";
612
- productReference = D53EA049112C7D59177F9878 /* Klepon.app */;
729
+ productReference = D53EA049112C7D59177F9878 /* Klepon Debug.app */;
730
productType = "com.apple.product-type.application";
731
};
732
E153DCB2727ADB891CF09A25 /* Klepon TV */ = {
@@ -619,18 +736,20 @@
736
00C4D42D71379B10BB8DAD32 /* Sources */,
737
13483CEA676E76809EF16344 /* Resources */,
738
614F30649198DB2180250038 /* Frameworks */,
739
+ 85563DDB067EE8F6CA513DC2 /* Embed Foundation Extensions */,
740
2444E8602E844C7B97E84E9D /* Update CFBundleVersion */,
741
);
742
buildRules = (
743
);
744
dependencies = (
745
+ E94F08ECA162F00774BE9E72 /* PBXTargetDependency */,
746
);
747
name = "Klepon TV";
748
packageProductDependencies = (
749
D977F065ACEF38E106A8FB5C /* Onde */,
750
);
751
productName = "Klepon TV";
633
- productReference = 754A7FAB8C92112B8B6228B7 /* Klepon.app */;
752
+ productReference = 754A7FAB8C92112B8B6228B7 /* Klepon Debug.app */;
753
productType = "com.apple.product-type.application";
754
};
755
E8CEBF52617721AEE450A7CF /* Klepon Watch */ = {
@@ -688,6 +807,9 @@
807
2ADB11F6C1D51B9F9A9DB15F = {
808
ProvisioningStyle = Automatic;
809
};
810
+ 62904071A31F700A13D6F432 = {
811
+ ProvisioningStyle = Automatic;
812
+ };
813
676CA86C78E7DB801DEEAF12 = {
814
ProvisioningStyle = Automatic;
815
};
@@ -723,6 +845,7 @@
845
2ADB11F6C1D51B9F9A9DB15F /* Klepon */,
846
676CA86C78E7DB801DEEAF12 /* Klepon Mac */,
847
E153DCB2727ADB891CF09A25 /* Klepon TV */,
848
+ 62904071A31F700A13D6F432 /* Klepon TV Top Shelf */,
849
0322696BE3965C4C0F0E5322 /* Klepon Vision */,
850
E8CEBF52617721AEE450A7CF /* Klepon Watch */,
851
F10E9562BA0BE60A7E000DB0 /* KleponSnapshotTests */,
@@ -791,6 +914,17 @@
914
);
915
runOnlyForDeploymentPostprocessing = 0;
916
};
917
+ C0709ADB9349E7813934595B /* Resources */ = {
918
+ isa = PBXResourcesBuildPhase;
919
+ buildActionMask = 2147483647;
920
+ files = (
921
+ A81DB2CF33EF6E8730010E87 /* topshelf-klepon.png in Resources */,
922
+ B5A8D8C810B7D57B637DA94C /* topshelf-rendang.png in Resources */,
923
+ F8C02BC1E76C4DCAC7C1F237 /* topshelf-sambal.png in Resources */,
924
+ B395C990F4E3A61631BBE48D /* topshelf-street-food.png in Resources */,
925
+ );
926
+ runOnlyForDeploymentPostprocessing = 0;
927
+ };
928
/* End PBXResourcesBuildPhase section */
929
930
/* Begin PBXShellScriptBuildPhase section */
@@ -814,6 +948,26 @@
948
shellScript = "set -eu\n\nSHARED_BUNDLE_VERSION_FILE=\"${BUILD_DIR}/klepon-cfbundleversion.txt\"\nif [ -f \"${SHARED_BUNDLE_VERSION_FILE}\" ]; then\n BUNDLE_VERSION=\"$(cat \"${SHARED_BUNDLE_VERSION_FILE}\")\"\nelse\n BUNDLE_VERSION=\"$(date -u +\"%Y%m%d.%H%M\")\"\n printf '%s' \"${BUNDLE_VERSION}\" > \"${SHARED_BUNDLE_VERSION_FILE}\"\nfi\n\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n\nif [ ! -f \"${INFO_PLIST}\" ]; then\n echo \"error: Info.plist not found at ${INFO_PLIST}\"\n exit 1\nfi\n\nif ! /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion ${BUNDLE_VERSION}\" \"${INFO_PLIST}\" >/dev/null 2>&1; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleVersion string ${BUNDLE_VERSION}\" \"${INFO_PLIST}\"\nfi\n\necho \"Updated CFBundleVersion to ${BUNDLE_VERSION}\"\n";
949
showEnvVarsInLog = 0;
950
};
951
+ 029DF4CC70CC3C4774474B0D /* Update CFBundleVersion */ = {
952
+ isa = PBXShellScriptBuildPhase;
953
+ alwaysOutOfDate = 1;
954
+ buildActionMask = 2147483647;
955
+ files = (
956
+ );
957
+ inputFileListPaths = (
958
+ );
959
+ inputPaths = (
960
+ );
961
+ name = "Update CFBundleVersion";
962
+ outputFileListPaths = (
963
+ );
964
+ outputPaths = (
965
+ );
966
+ runOnlyForDeploymentPostprocessing = 0;
967
+ shellPath = /bin/sh;
968
+ shellScript = "set -eu\n\nSHARED_BUNDLE_VERSION_FILE=\"${BUILD_DIR}/klepon-cfbundleversion.txt\"\nif [ -f \"${SHARED_BUNDLE_VERSION_FILE}\" ]; then\n BUNDLE_VERSION=\"$(cat \"${SHARED_BUNDLE_VERSION_FILE}\")\"\nelse\n BUNDLE_VERSION=\"$(date -u +\"%Y%m%d.%H%M\")\"\n printf '%s' \"${BUNDLE_VERSION}\" > \"${SHARED_BUNDLE_VERSION_FILE}\"\nfi\n\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n\nif [ ! -f \"${INFO_PLIST}\" ]; then\n echo \"error: Info.plist not found at ${INFO_PLIST}\"\n exit 1\nfi\n\nif ! /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion ${BUNDLE_VERSION}\" \"${INFO_PLIST}\" >/dev/null 2>&1; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleVersion string ${BUNDLE_VERSION}\" \"${INFO_PLIST}\"\nfi\n\necho \"Updated CFBundleVersion to ${BUNDLE_VERSION}\"\n";
969
+ showEnvVarsInLog = 0;
970
+ };
971
201C2DC58537433FDB96C0C5 /* Update CFBundleVersion */ = {
972
isa = PBXShellScriptBuildPhase;
973
alwaysOutOfDate = 1;
@@ -941,11 +1095,14 @@
1095
isa = PBXSourcesBuildPhase;
1096
buildActionMask = 2147483647;
1097
files = (
1098
+ 3C39523ADE9453A7043216B8 /* AccountStore.swift in Sources */,
1099
+ 609532817F88D0D6F1E3CB16 /* AccountView.swift in Sources */,
1100
8189E2DF13801C2B7162AD45 /* AnswerCard.swift in Sources */,
945
- 935A2F7D98CCC0DEA5EA2782 /* AppRootView.swift in Sources */,
1101
+ 972FD85EF2549D2C82EA35A1 /* AppDelegate.swift in Sources */,
1102
1E042510F7F75D392A4AC6E7 /* AppState.swift in Sources */,
1103
26FA5D01FED96591E78A2293 /* AskSheetView.swift in Sources */,
1104
854C10AE603DCCC521D93BC2 /* ContentRepository.swift in Sources */,
1105
+ 9AE51D59952308FED1DD538B /* DebugMenuView.swift in Sources */,
1106
997D63C43B61CB342C586FB8 /* DiscoverView.swift in Sources */,
1107
6B7AE8B1C6D3AD6F973F5757 /* EmptyStateView.swift in Sources */,
1108
C8ECF8664277103582D96B45 /* FavoritesStore.swift in Sources */,
@@ -955,11 +1112,11 @@
1112
F9983D842EB95B43620CA52B /* GuideDetailView.swift in Sources */,
1113
E25DE24C663A9579D28F21D4 /* GuideEntry.swift in Sources */,
1114
B97D6D5885E0B6CFB358B1BC /* KleponActionButton.swift in Sources */,
958
- DBD7CE5D9AB42EA74CC9F7A1 /* KleponApp.swift in Sources */,
1115
16330914DF2EE212C91BC0CA /* KleponCard.swift in Sources */,
1116
587F0BB61966B2BB048013DE /* KleponChip.swift in Sources */,
1117
4B6E0CF8EAB6FF1D0160E3E6 /* KleponColor.swift in Sources */,
1118
CE6824D3D8261BF0463F39D8 /* KleponMetadataRow.swift in Sources */,
1119
+ 53D759A429886E0FD8E5E9BF /* KleponTabBarController.swift in Sources */,
1120
DDD3529FAEA0D105EBD5C168 /* KleponTypography.swift in Sources */,
1121
6117CD728CC7FF8D6EB0A8C8 /* OnboardingView.swift in Sources */,
1122
E17595636392EDF3D6EEE895 /* OndeEnvironmentBootstrap.swift in Sources */,
@@ -968,6 +1125,7 @@
1125
819489D60D833AE2AD9185A6 /* RecentSearchStore.swift in Sources */,
1126
A0EF936D6223961D25CBC9F6 /* RecentlyViewedStore.swift in Sources */,
1127
14C796CE6777B6013910831C /* SavedView.swift in Sources */,
1128
+ 2C576E80607B50FC80C97A08 /* SceneDelegate.swift in Sources */,
1129
74A9016453B9820FC486B014 /* SearchService.swift in Sources */,
1130
C2A06A0802694BD0C1F39B85 /* SearchView.swift in Sources */,
1131
7F726FA99DBDC0155E99E098 /* SectionHeader.swift in Sources */,
@@ -979,11 +1137,14 @@
1137
isa = PBXSourcesBuildPhase;
1138
buildActionMask = 2147483647;
1139
files = (
1140
+ 46D9C39408990F1C332EA957 /* AccountStore.swift in Sources */,
1141
+ EAD7B26527734629EC834D13 /* AccountView.swift in Sources */,
1142
23E22EC56BE48A20BD1229F7 /* AnswerCard.swift in Sources */,
1143
9ECA989E9DCDF08A2D7C29A3 /* AppRootView.swift in Sources */,
1144
8401F9C0FA140DC2CF8BA248 /* AppState.swift in Sources */,
1145
FA423A76BE098A9911044246 /* AskSheetView.swift in Sources */,
1146
C709C82E7F0BCF398BCD9C2F /* ContentRepository.swift in Sources */,
1147
+ BC40004C4FFBC035CA9DDD6F /* DebugMenuView.swift in Sources */,
1148
19E282A6347C588E17778195 /* DiscoverView.swift in Sources */,
1149
DA915BA8B2F732E7D62CF936 /* EmptyStateView.swift in Sources */,
1150
200AE94E7E7E73B5C83869DD /* FavoritesStore.swift in Sources */,
@@ -1030,11 +1191,14 @@
1191
isa = PBXSourcesBuildPhase;
1192
buildActionMask = 2147483647;
1193
files = (
1194
+ 7E6755E542F15B6921C21604 /* AccountStore.swift in Sources */,
1195
+ CA34777FEEC140D938934C01 /* AccountView.swift in Sources */,
1196
687418866BCC5554A0F4BFDC /* AnswerCard.swift in Sources */,
1197
F88972525510344E63D2983C /* AppRootView.swift in Sources */,
1198
D6D8A42930EA9A0EA6622108 /* AppState.swift in Sources */,
1199
EE2103530984735318E469FD /* AskSheetView.swift in Sources */,
1200
F87EF3C291B7D3FB9BE8E43A /* ContentRepository.swift in Sources */,
1201
+ CA9472623EBA8F24348A9F05 /* DebugMenuView.swift in Sources */,
1202
9E16F5C37F30B58441780260 /* DiscoverView.swift in Sources */,
1203
2AD05B0B7ED109134F013862 /* EmptyStateView.swift in Sources */,
1204
F0694A813D1CB57F0B5C0F65 /* FavoritesStore.swift in Sources */,
@@ -1064,15 +1228,26 @@
1228
);
1229
runOnlyForDeploymentPostprocessing = 0;
1230
};
1231
+ 92DEEB7F16A4D2F734A3DE48 /* Sources */ = {
1232
+ isa = PBXSourcesBuildPhase;
1233
+ buildActionMask = 2147483647;
1234
+ files = (
1235
+ CAF237DB042D25046A64CD4D /* ContentProvider.swift in Sources */,
1236
+ );
1237
+ runOnlyForDeploymentPostprocessing = 0;
1238
+ };
1239
BFE208AF41AAF322DFAB7EED /* Sources */ = {
1240
isa = PBXSourcesBuildPhase;
1241
buildActionMask = 2147483647;
1242
files = (
1243
+ 0E5D1E78C77AD38DD7A3D7F6 /* AccountStore.swift in Sources */,
1244
+ 2A3F2C3AF20192B1FCE71B56 /* AccountView.swift in Sources */,
1245
CE5C9946E68E1F7A781F1628 /* AnswerCard.swift in Sources */,
1246
0481C8A85FED1E35C3734816 /* AppRootView.swift in Sources */,
1247
906E12C00728B15B676A23DB /* AppState.swift in Sources */,
1248
2F74C3B8D4A1A38DEF50E39B /* AskSheetView.swift in Sources */,
1249
20CDEBB4B6C8AAE007854102 /* ContentRepository.swift in Sources */,
1250
+ 99C4E0BC590F52400F28A5A4 /* DebugMenuView.swift in Sources */,
1251
A001621A9DB5FF868217D410 /* DiscoverView.swift in Sources */,
1252
EDD126593FECA8D9EB7DBC4A /* EmptyStateView.swift in Sources */,
1253
68479892D13825C6C9977DF9 /* FavoritesStore.swift in Sources */,
@@ -1123,6 +1298,11 @@
1298
target = 2ADB11F6C1D51B9F9A9DB15F /* Klepon */;
1299
targetProxy = C24CD5A7B8399776539D1FF4 /* PBXContainerItemProxy */;
1300
};
1301
+ E94F08ECA162F00774BE9E72 /* PBXTargetDependency */ = {
1302
+ isa = PBXTargetDependency;
1303
+ target = 62904071A31F700A13D6F432 /* Klepon TV Top Shelf */;
1304
+ targetProxy = F5DCD3DD3B0670276438A4D3 /* PBXContainerItemProxy */;
1305
+ };
1306
/* End PBXTargetDependency section */
1307
1308
/* Begin XCBuildConfiguration section */
@@ -1178,12 +1358,13 @@
1358
"EXCLUDED_ARCHS[sdk=watchsimulator*]" = x86_64;
1359
GENERATE_INFOPLIST_FILE = NO;
1360
INFOPLIST_FILE = KleponWatch/Info.plist;
1361
+ KLEPON_COMPANION_BUNDLE_ID = "ai.splitfire.klepon-Debug";
1362
LD_RUNPATH_SEARCH_PATHS = (
1363
"$(inherited)",
1364
"@executable_path/Frameworks",
1365
);
1366
MARKETING_VERSION = 1.0.0;
1186
- PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon.watchkitapp;
1367
+ PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug.watchkitapp";
1368
PRODUCT_NAME = "Klepon Watch";
1369
SDKROOT = watchos;
1370
SKIP_INSTALL = YES;
@@ -1192,6 +1373,28 @@
1373
};
1374
name = Debug;
1375
};
1376
+ 6F67020609BFAC03B751AA27 /* Release */ = {
1377
+ isa = XCBuildConfiguration;
1378
+ baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
1379
+ buildSettings = {
1380
+ APPLICATION_EXTENSION_API_ONLY = YES;
1381
+ CURRENT_PROJECT_VERSION = 1;
1382
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
1383
+ INFOPLIST_FILE = KleponTVTopShelfExtension/Info.plist;
1384
+ LD_RUNPATH_SEARCH_PATHS = (
1385
+ "$(inherited)",
1386
+ "@executable_path/Frameworks",
1387
+ "@executable_path/../../Frameworks",
1388
+ );
1389
+ MARKETING_VERSION = 1.0.0;
1390
+ PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon.topshelf;
1391
+ PRODUCT_NAME = "Klepon TV Top Shelf";
1392
+ SDKROOT = appletvos;
1393
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
1394
+ TARGETED_DEVICE_FAMILY = 3;
1395
+ };
1396
+ name = Release;
1397
+ };
1398
73320483BA240362017C25C8 /* Debug */ = {
1399
isa = XCBuildConfiguration;
1400
baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
@@ -1201,6 +1404,7 @@
1404
CODE_SIGN_ENTITLEMENTS = KleponMac/KleponMac.entitlements;
1405
COMBINE_HIDPI_IMAGES = YES;
1406
CURRENT_PROJECT_VERSION = 1;
1407
+ DEAD_CODE_STRIPPING = YES;
1408
ENABLE_USER_SCRIPT_SANDBOXING = NO;
1409
"EXCLUDED_ARCHS[sdk=macosx*]" = x86_64;
1410
INFOPLIST_FILE = KleponMac/Info.plist;
@@ -1210,8 +1414,8 @@
1414
"@executable_path/../Frameworks",
1415
);
1416
MARKETING_VERSION = 1.0.0;
1213
- PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1214
- PRODUCT_NAME = Klepon;
1417
+ PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug";
1418
+ PRODUCT_NAME = "Klepon Debug";
1419
SDKROOT = macosx;
1420
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1421
};
@@ -1226,6 +1430,7 @@
1430
CODE_SIGN_ENTITLEMENTS = KleponMac/KleponMac.entitlements;
1431
COMBINE_HIDPI_IMAGES = YES;
1432
CURRENT_PROJECT_VERSION = 1;
1433
+ DEAD_CODE_STRIPPING = YES;
1434
ENABLE_USER_SCRIPT_SANDBOXING = NO;
1435
"EXCLUDED_ARCHS[sdk=macosx*]" = x86_64;
1436
INFOPLIST_FILE = KleponMac/Info.plist;
@@ -1317,9 +1522,9 @@
1522
"$(inherited)",
1523
"@executable_path/Frameworks",
1524
);
1320
- MARKETING_VERSION = 1.0.0;
1321
- PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1322
- PRODUCT_NAME = Klepon;
1525
+ MARKETING_VERSION = 1.0.1;
1526
+ PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug";
1527
+ PRODUCT_NAME = "Klepon Debug";
1528
SDKROOT = appletvos;
1529
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1530
TARGETED_DEVICE_FAMILY = 3;
@@ -1328,13 +1533,14 @@
1533
};
1534
97B8585F2E74C6C870509FE9 /* Release */ = {
1535
isa = XCBuildConfiguration;
1331
- baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
1536
+ baseConfigurationReference = 95321199A19014C816398CAC /* Release.xcconfig */;
1537
buildSettings = {
1538
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1539
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1540
CODE_SIGN_ENTITLEMENTS = Klepon/Klepon.entitlements;
1541
CODE_SIGN_IDENTITY = "iPhone Developer";
1542
CURRENT_PROJECT_VERSION = 1;
1543
+ DEAD_CODE_STRIPPING = YES;
1544
ENABLE_USER_SCRIPT_SANDBOXING = NO;
1545
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
1546
INFOPLIST_FILE = Klepon/Info.plist;
@@ -1342,7 +1548,7 @@
1548
"$(inherited)",
1549
"@executable_path/Frameworks",
1550
);
1345
- MARKETING_VERSION = 1.0.1;
1551
+ MARKETING_VERSION = 1.0.0;
1552
PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1553
SDKROOT = iphoneos;
1554
SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -1374,6 +1580,28 @@
1580
};
1581
name = Release;
1582
};
1583
+ 9EB7452B8A132D90E41C09DE /* Debug */ = {
1584
+ isa = XCBuildConfiguration;
1585
+ baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
1586
+ buildSettings = {
1587
+ APPLICATION_EXTENSION_API_ONLY = YES;
1588
+ CURRENT_PROJECT_VERSION = 1;
1589
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
1590
+ INFOPLIST_FILE = KleponTVTopShelfExtension/Info.plist;
1591
+ LD_RUNPATH_SEARCH_PATHS = (
1592
+ "$(inherited)",
1593
+ "@executable_path/Frameworks",
1594
+ "@executable_path/../../Frameworks",
1595
+ );
1596
+ MARKETING_VERSION = 1.0.0;
1597
+ PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug.topshelf";
1598
+ PRODUCT_NAME = "Klepon TV Top Shelf";
1599
+ SDKROOT = appletvos;
1600
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1601
+ TARGETED_DEVICE_FAMILY = 3;
1602
+ };
1603
+ name = Debug;
1604
+ };
1605
A3B469B57456DA7C46537A7E /* Release */ = {
1606
isa = XCBuildConfiguration;
1607
baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
@@ -1386,6 +1614,7 @@
1614
"EXCLUDED_ARCHS[sdk=watchsimulator*]" = x86_64;
1615
GENERATE_INFOPLIST_FILE = NO;
1616
INFOPLIST_FILE = KleponWatch/Info.plist;
1617
+ KLEPON_COMPANION_BUNDLE_ID = ai.splitfire.klepon;
1618
LD_RUNPATH_SEARCH_PATHS = (
1619
"$(inherited)",
1620
"@executable_path/Frameworks",
@@ -1482,7 +1711,7 @@
1711
"$(inherited)",
1712
"@executable_path/Frameworks",
1713
);
1485
- MARKETING_VERSION = 1.0.0;
1714
+ MARKETING_VERSION = 1.0.1;
1715
PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1716
PRODUCT_NAME = Klepon;
1717
SDKROOT = appletvos;
@@ -1493,13 +1722,14 @@
1722
};
1723
D2D1439E7A622A6F8019887F /* Debug */ = {
1724
isa = XCBuildConfiguration;
1496
- baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
1725
+ baseConfigurationReference = B868F6866D5C0497F3672F12 /* Debug.xcconfig */;
1726
buildSettings = {
1727
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1728
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1729
CODE_SIGN_ENTITLEMENTS = Klepon/Klepon.entitlements;
1730
CODE_SIGN_IDENTITY = "iPhone Developer";
1731
CURRENT_PROJECT_VERSION = 1;
1732
+ DEAD_CODE_STRIPPING = YES;
1733
ENABLE_USER_SCRIPT_SANDBOXING = NO;
1734
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
1735
INFOPLIST_FILE = Klepon/Info.plist;
@@ -1507,8 +1737,9 @@
1737
"$(inherited)",
1738
"@executable_path/Frameworks",
1739
);
1510
- MARKETING_VERSION = 1.0.1;
1511
- PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1740
+ MARKETING_VERSION = 1.0.0;
1741
+ PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug";
1742
+ PRODUCT_NAME = "Klepon Debug";
1743
SDKROOT = iphoneos;
1744
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1745
TARGETED_DEVICE_FAMILY = "1,2";
@@ -1531,8 +1762,8 @@
1762
"@executable_path/Frameworks",
1763
);
1764
MARKETING_VERSION = 1.0.0;
1534
- PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1535
- PRODUCT_NAME = Klepon;
1765
+ PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug";
1766
+ PRODUCT_NAME = "Klepon Debug";
1767
SDKROOT = xros;
1768
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1769
TARGETED_DEVICE_FAMILY = 7;
@@ -1569,6 +1800,15 @@
1800
defaultConfigurationIsVisible = 0;
1801
defaultConfigurationName = Debug;
1802
};
1803
+ 29C34512D4ABC3CD6704E6D3 /* Build configuration list for PBXNativeTarget "Klepon TV Top Shelf" */ = {
1804
+ isa = XCConfigurationList;
1805
+ buildConfigurations = (
1806
+ 9EB7452B8A132D90E41C09DE /* Debug */,
1807
+ 6F67020609BFAC03B751AA27 /* Release */,
1808
+ );
1809
+ defaultConfigurationIsVisible = 0;
1810
+ defaultConfigurationName = Debug;
1811
+ };
1812
78899F7C62A9675700D42C5A /* Build configuration list for PBXNativeTarget "Klepon Mac" */ = {
1813
isa = XCConfigurationList;
1814
buildConfigurations = (
@@ -1621,7 +1861,7 @@
1861
repositoryURL = "https://github.com/ondeinference/onde-swift";
1862
requirement = {
1863
kind = exactVersion;
1624
- version = 1.1.0;
1864
+ version = 1.1.2;
1865
};
1866
};
1867
/* End XCRemoteSwiftPackageReference section */
Klepon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+2
-2
@@ -6,8 +6,8 @@
6
"kind" : "remoteSourceControl",
7
"location" : "https://github.com/ondeinference/onde-swift",
8
"state" : {
9
- "revision" : "d121bc121c2234964e71569e86cbb93cc750361e",
10
- "version" : "1.1.0"
9
+ "revision" : "534c412ee719e1a4c7c5f1f8b24c1c4bf5188878",
10
+ "version" : "1.1.2"
11
}
12
},
13
{
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon Mac.xcscheme
+4
-4
@@ -15,7 +15,7 @@
15
<BuildableReference
16
BuildableIdentifier = "primary"
17
BlueprintIdentifier = "676CA86C78E7DB801DEEAF12"
18
- BuildableName = "Klepon.app"
18
+ BuildableName = "Klepon Debug.app"
19
BlueprintName = "Klepon Mac"
20
ReferencedContainer = "container:Klepon.xcodeproj">
21
</BuildableReference>
@@ -31,7 +31,7 @@
31
<BuildableReference
32
BuildableIdentifier = "primary"
33
BlueprintIdentifier = "676CA86C78E7DB801DEEAF12"
34
- BuildableName = "Klepon.app"
34
+ BuildableName = "Klepon Debug.app"
35
BlueprintName = "Klepon Mac"
36
ReferencedContainer = "container:Klepon.xcodeproj">
37
</BuildableReference>
@@ -54,7 +54,7 @@
54
<BuildableReference
55
BuildableIdentifier = "primary"
56
BlueprintIdentifier = "676CA86C78E7DB801DEEAF12"
57
- BuildableName = "Klepon.app"
57
+ BuildableName = "Klepon Debug.app"
58
BlueprintName = "Klepon Mac"
59
ReferencedContainer = "container:Klepon.xcodeproj">
60
</BuildableReference>
@@ -71,7 +71,7 @@
71
<BuildableReference
72
BuildableIdentifier = "primary"
73
BlueprintIdentifier = "676CA86C78E7DB801DEEAF12"
74
- BuildableName = "Klepon.app"
74
+ BuildableName = "Klepon Debug.app"
75
BlueprintName = "Klepon Mac"
76
ReferencedContainer = "container:Klepon.xcodeproj">
77
</BuildableReference>
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon TV.xcscheme
+4
-4
@@ -15,7 +15,7 @@
15
<BuildableReference
16
BuildableIdentifier = "primary"
17
BlueprintIdentifier = "E153DCB2727ADB891CF09A25"
18
- BuildableName = "Klepon.app"
18
+ BuildableName = "Klepon Debug.app"
19
BlueprintName = "Klepon TV"
20
ReferencedContainer = "container:Klepon.xcodeproj">
21
</BuildableReference>
@@ -31,7 +31,7 @@
31
<BuildableReference
32
BuildableIdentifier = "primary"
33
BlueprintIdentifier = "E153DCB2727ADB891CF09A25"
34
- BuildableName = "Klepon.app"
34
+ BuildableName = "Klepon Debug.app"
35
BlueprintName = "Klepon TV"
36
ReferencedContainer = "container:Klepon.xcodeproj">
37
</BuildableReference>
@@ -54,7 +54,7 @@
54
<BuildableReference
55
BuildableIdentifier = "primary"
56
BlueprintIdentifier = "E153DCB2727ADB891CF09A25"
57
- BuildableName = "Klepon.app"
57
+ BuildableName = "Klepon Debug.app"
58
BlueprintName = "Klepon TV"
59
ReferencedContainer = "container:Klepon.xcodeproj">
60
</BuildableReference>
@@ -71,7 +71,7 @@
71
<BuildableReference
72
BuildableIdentifier = "primary"
73
BlueprintIdentifier = "E153DCB2727ADB891CF09A25"
74
- BuildableName = "Klepon.app"
74
+ BuildableName = "Klepon Debug.app"
75
BlueprintName = "Klepon TV"
76
ReferencedContainer = "container:Klepon.xcodeproj">
77
</BuildableReference>
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon Vision.xcscheme
+4
-4
@@ -15,7 +15,7 @@
15
<BuildableReference
16
BuildableIdentifier = "primary"
17
BlueprintIdentifier = "0322696BE3965C4C0F0E5322"
18
- BuildableName = "Klepon.app"
18
+ BuildableName = "Klepon Debug.app"
19
BlueprintName = "Klepon Vision"
20
ReferencedContainer = "container:Klepon.xcodeproj">
21
</BuildableReference>
@@ -31,7 +31,7 @@
31
<BuildableReference
32
BuildableIdentifier = "primary"
33
BlueprintIdentifier = "0322696BE3965C4C0F0E5322"
34
- BuildableName = "Klepon.app"
34
+ BuildableName = "Klepon Debug.app"
35
BlueprintName = "Klepon Vision"
36
ReferencedContainer = "container:Klepon.xcodeproj">
37
</BuildableReference>
@@ -54,7 +54,7 @@
54
<BuildableReference
55
BuildableIdentifier = "primary"
56
BlueprintIdentifier = "0322696BE3965C4C0F0E5322"
57
- BuildableName = "Klepon.app"
57
+ BuildableName = "Klepon Debug.app"
58
BlueprintName = "Klepon Vision"
59
ReferencedContainer = "container:Klepon.xcodeproj">
60
</BuildableReference>
@@ -71,7 +71,7 @@
71
<BuildableReference
72
BuildableIdentifier = "primary"
73
BlueprintIdentifier = "0322696BE3965C4C0F0E5322"
74
- BuildableName = "Klepon.app"
74
+ BuildableName = "Klepon Debug.app"
75
BlueprintName = "Klepon Vision"
76
ReferencedContainer = "container:Klepon.xcodeproj">
77
</BuildableReference>
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon.xcscheme
+15
-22
@@ -1,11 +1,10 @@
1
<?xml version="1.0" encoding="UTF-8"?>
2
<Scheme
3
LastUpgradeVersion = "2600"
4
- version = "1.7">
4
+ version = "1.3">
5
<BuildAction
6
parallelizeBuildables = "YES"
7
- buildImplicitDependencies = "YES"
8
- runPostActionsOnFailure = "NO">
7
+ buildImplicitDependencies = "YES">
8
<BuildActionEntries>
9
<BuildActionEntry
10
buildForTesting = "YES"
@@ -16,7 +15,7 @@
15
<BuildableReference
16
BuildableIdentifier = "primary"
17
BlueprintIdentifier = "2ADB11F6C1D51B9F9A9DB15F"
19
- BuildableName = "Klepon.app"
18
+ BuildableName = "Klepon Debug.app"
19
BlueprintName = "Klepon"
20
ReferencedContainer = "container:Klepon.xcodeproj">
21
</BuildableReference>
@@ -48,11 +47,20 @@
47
<BuildableReference
48
BuildableIdentifier = "primary"
49
BlueprintIdentifier = "2ADB11F6C1D51B9F9A9DB15F"
51
- BuildableName = "Klepon.app"
50
+ BuildableName = "Klepon Debug.app"
51
BlueprintName = "Klepon"
52
ReferencedContainer = "container:Klepon.xcodeproj">
53
</BuildableReference>
54
</MacroExpansion>
55
+ <CodeCoverageTargets>
56
+ <BuildableReference
57
+ BuildableIdentifier = "primary"
58
+ BlueprintIdentifier = "2ADB11F6C1D51B9F9A9DB15F"
59
+ BuildableName = "Klepon Debug.app"
60
+ BlueprintName = "Klepon"
61
+ ReferencedContainer = "container:Klepon.xcodeproj">
62
+ </BuildableReference>
63
+ </CodeCoverageTargets>
64
<Testables>
65
<TestableReference
66
skipped = "NO"
@@ -66,17 +74,6 @@
74
</BuildableReference>
75
</TestableReference>
76
</Testables>
69
- <CommandLineArguments>
70
- </CommandLineArguments>
71
- <CodeCoverageTargets>
72
- <BuildableReference
73
- BuildableIdentifier = "primary"
74
- BlueprintIdentifier = "2ADB11F6C1D51B9F9A9DB15F"
75
- BuildableName = "Klepon.app"
76
- BlueprintName = "Klepon"
77
- ReferencedContainer = "container:Klepon.xcodeproj">
78
- </BuildableReference>
79
- </CodeCoverageTargets>
77
</TestAction>
78
<LaunchAction
79
buildConfiguration = "Debug"
@@ -93,13 +90,11 @@
90
<BuildableReference
91
BuildableIdentifier = "primary"
92
BlueprintIdentifier = "2ADB11F6C1D51B9F9A9DB15F"
96
- BuildableName = "Klepon.app"
93
+ BuildableName = "Klepon Debug.app"
94
BlueprintName = "Klepon"
95
ReferencedContainer = "container:Klepon.xcodeproj">
96
</BuildableReference>
97
</BuildableProductRunnable>
101
- <CommandLineArguments>
102
- </CommandLineArguments>
98
</LaunchAction>
99
<ProfileAction
100
buildConfiguration = "Release"
@@ -112,13 +107,11 @@
107
<BuildableReference
108
BuildableIdentifier = "primary"
109
BlueprintIdentifier = "2ADB11F6C1D51B9F9A9DB15F"
115
- BuildableName = "Klepon.app"
110
+ BuildableName = "Klepon Debug.app"
111
BlueprintName = "Klepon"
112
ReferencedContainer = "container:Klepon.xcodeproj">
113
</BuildableReference>
114
</BuildableProductRunnable>
120
- <CommandLineArguments>
121
- </CommandLineArguments>
115
</ProfileAction>
116
<AnalyzeAction
117
buildConfiguration = "Debug">
Klepon/AI/OndeEnvironmentBootstrap.swift
+117
-16
@@ -11,23 +11,33 @@ enum OndeEnvironmentBootstrap {
11
private static var didConfigure = false
12
13
static var estimatedDownloadDescription: String {
14
- "About 941 MB"
14
+ #if os(tvOS)
15
+ "About 380 MB"
16
+ #else
17
+ "About 941 MB"
18
+ #endif
19
}
20
21
static func configureIfNeeded() {
18
- guard !didConfigure, let paths = paths() else { return }
22
+ guard !didConfigure else { return }
23
20
- [paths.baseURL, paths.hfHomeURL, paths.hfHubCacheURL, paths.temporaryURL].forEach {
21
- try? FileManager.default.createDirectory(
22
- at: $0,
23
- withIntermediateDirectories: true,
24
- attributes: nil
24
+ guard let paths = paths() else {
25
+ NSLog(
26
+ "[OndeEnvironmentBootstrap] ERROR: could not find any writable directory for HF cache"
27
)
28
+ return
29
}
30
31
+ NSLog("[OndeEnvironmentBootstrap] Using base directory: %@", paths.baseURL.path)
32
+
33
setenv("HF_HOME", paths.hfHomeURL.path, 1)
34
setenv("HF_HUB_CACHE", paths.hfHubCacheURL.path, 1)
35
+ setenv("HUGGINGFACE_HUB_CACHE", paths.hfHubCacheURL.path, 1)
36
setenv("TMPDIR", paths.temporaryURL.path, 1)
37
+
38
+ NSLog("[OndeEnvironmentBootstrap] HF_HOME=%@", paths.hfHomeURL.path)
39
+ NSLog("[OndeEnvironmentBootstrap] HF_HUB_CACHE=%@", paths.hfHubCacheURL.path)
40
+
41
didConfigure = true
42
}
43
@@ -47,23 +57,73 @@ enum OndeEnvironmentBootstrap {
57
configureIfNeeded()
58
}
59
60
+ // MARK: - Path resolution
61
+
62
+ /// Resolve the best writable base directory for model storage.
63
+ ///
64
+ /// Priority order:
65
+ /// 1. App Group container — persistent, shared across apps in the group.
66
+ /// This is the preferred location matching splitfire/pepakbasajawa.
67
+ /// 2. Library/Caches (tvOS only) — writable but can be purged by the system.
68
+ /// 3. Library/Application Support — persistent, app-private.
69
+ /// 4. Temporary directory — always writable, last resort.
70
+ ///
71
+ /// Instead of a separate writability probe, we try to create the actual
72
+ /// subdirectories we need (models/hub/, tmp/). If that succeeds, the path
73
+ /// is usable.
74
static func paths() -> Paths? {
75
let fileManager = FileManager.default
76
53
- let baseURL: URL
54
- if let sharedContainer = fileManager.containerURL(
77
+ // 1. App Group container (highest priority — persistent + shared)
78
+ if let appGroupURL = fileManager.containerURL(
79
forSecurityApplicationGroupIdentifier: "group.com.ondeinference.apps"
80
) {
57
- baseURL = sharedContainer
58
- } else if let appSupportURL = fileManager.urls(
59
- for: .applicationSupportDirectory,
60
- in: .userDomainMask
61
- ).first {
62
- baseURL = appSupportURL
81
+ NSLog("[OndeEnvironmentBootstrap] App Group URL returned: %@", appGroupURL.path)
82
+
83
+ let paths = makePaths(from: appGroupURL)
84
+ if tryCreateDirectories(paths, label: "AppGroup") {
85
+ return paths
86
+ }
87
} else {
64
- return nil
88
+ NSLog("[OndeEnvironmentBootstrap] App Group container returned nil")
89
+ }
90
+
91
+ // 2. Library/Application Support/OndeCache (persistent, survives app updates)
92
+ if let appSupportURL = fileManager.urls(
93
+ for: .applicationSupportDirectory, in: .userDomainMask
94
+ ).first {
95
+ let supportBase = appSupportURL.appendingPathComponent(
96
+ "OndeCache", isDirectory: true)
97
+ let paths = makePaths(from: supportBase)
98
+ if tryCreateDirectories(paths, label: "ApplicationSupport") {
99
+ return paths
100
+ }
101
+ }
102
+
103
+ // 3. Library/Caches/OndeCache (writable, but purgeable — wiped on reinstall)
104
+ #if os(tvOS)
105
+ if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
106
+ let cacheBase = cachesURL.appendingPathComponent("OndeCache", isDirectory: true)
107
+ let paths = makePaths(from: cacheBase)
108
+ if tryCreateDirectories(paths, label: "Caches") {
109
+ return paths
110
+ }
111
+ }
112
+ #endif
113
+
114
+ // 4. Hard fallback: temporary directory
115
+ let fallback = fileManager.temporaryDirectory
116
+ .appendingPathComponent("OndeCache", isDirectory: true)
117
+ let paths = makePaths(from: fallback)
118
+ if tryCreateDirectories(paths, label: "TempDir") {
119
+ NSLog("[OndeEnvironmentBootstrap] Using temp fallback: %@", fallback.path)
120
+ return paths
121
}
122
123
+ return nil
124
+ }
125
+
126
+ private static func makePaths(from baseURL: URL) -> Paths {
127
let hfHomeURL = baseURL.appendingPathComponent("models", isDirectory: true)
128
let hfHubCacheURL = hfHomeURL.appendingPathComponent("hub", isDirectory: true)
129
let temporaryURL = baseURL.appendingPathComponent("tmp", isDirectory: true)
@@ -76,6 +136,47 @@ enum OndeEnvironmentBootstrap {
136
)
137
}
138
139
+ /// Try to create the actual directory tree we need.
140
+ /// Returns true if all directories were created (or already exist).
141
+ private static func tryCreateDirectories(_ paths: Paths, label: String) -> Bool {
142
+ let fileManager = FileManager.default
143
+ let dirs = [paths.hfHubCacheURL, paths.temporaryURL]
144
+
145
+ for dir in dirs {
146
+ do {
147
+ try fileManager.createDirectory(
148
+ at: dir,
149
+ withIntermediateDirectories: true,
150
+ attributes: nil
151
+ )
152
+ } catch {
153
+ NSLog(
154
+ "[OndeEnvironmentBootstrap] %@ not usable — failed to create %@: %@",
155
+ label, dir.path, error.localizedDescription
156
+ )
157
+ return false
158
+ }
159
+ }
160
+
161
+ // Verify we can actually write a file (not just create dirs)
162
+ let probeFile = paths.temporaryURL.appendingPathComponent("probe")
163
+ do {
164
+ try Data([0x42]).write(to: probeFile)
165
+ try fileManager.removeItem(at: probeFile)
166
+ } catch {
167
+ NSLog(
168
+ "[OndeEnvironmentBootstrap] %@ not usable — write probe failed: %@",
169
+ label, error.localizedDescription
170
+ )
171
+ return false
172
+ }
173
+
174
+ NSLog("[OndeEnvironmentBootstrap] %@ is writable: %@", label, paths.baseURL.path)
175
+ return true
176
+ }
177
+
178
+ // MARK: - Helpers
179
+
180
private static func directorySize(at url: URL) -> Int64 {
181
let fileManager = FileManager.default
182
guard
Klepon/AI/OndeGuideEngine.swift
+65
-3
@@ -2,6 +2,10 @@ import Combine
2
import Foundation
3
import Onde
4
5
+#if os(tvOS)
6
+ import Darwin
7
+#endif
8
+
9
enum PrivateGuideAvailability: Equatable {
10
case notInstalled
11
case downloaded
@@ -9,6 +13,7 @@ enum PrivateGuideAvailability: Equatable {
13
case ready
14
case answering
15
case failed(String)
16
+ case unsupported(String)
17
18
var title: String {
19
switch self {
@@ -24,6 +29,8 @@ enum PrivateGuideAvailability: Equatable {
29
return "Answering privately"
30
case .failed:
31
return "Private guide needs attention"
32
+ case .unsupported:
33
+ return "Private guide not available on this device"
34
}
35
}
36
@@ -46,6 +53,8 @@ enum PrivateGuideAvailability: Equatable {
53
"Klepon is grounding your question against the local guide and composing a concise answer."
54
case .failed(let message):
55
return message
56
+ case .unsupported(let reason):
57
+ return reason
58
}
59
}
60
@@ -57,6 +66,8 @@ enum PrivateGuideAvailability: Equatable {
66
return "Try again"
67
case .downloaded:
68
return "Finish preparing private guide"
69
+ case .unsupported:
70
+ return "Not available"
71
default:
72
return "Prepare private guide"
73
}
@@ -77,6 +88,13 @@ enum PrivateGuideAvailability: Equatable {
88
}
89
return false
90
}
91
+
92
+ var isUnsupported: Bool {
93
+ if case .unsupported = self {
94
+ return true
95
+ }
96
+ return false
97
+ }
98
}
99
100
enum OndeGuideError: LocalizedError {
@@ -90,6 +108,27 @@ enum OndeGuideError: LocalizedError {
108
}
109
}
110
111
+#if os(tvOS)
112
+ /// Check if this Apple TV has the Metal capabilities needed for on-device inference.
113
+ /// AppleTV14,1 (Apple TV 4K 3rd gen, A15) is the minimum. Older chips (A10X, A12)
114
+ /// lack simdgroup_matrix support and crash at shader compilation.
115
+ func kleponDeviceSupportsInference() -> Bool {
116
+ var size = 0
117
+ sysctlbyname("hw.machine", nil, &size, nil, 0)
118
+ var machine = [CChar](repeating: 0, count: size)
119
+ sysctlbyname("hw.machine", &machine, &size, nil, 0)
120
+ let identifier = String(cString: machine)
121
+
122
+ guard identifier.hasPrefix("AppleTV") else { return false }
123
+ let numericPart = identifier.dropFirst("AppleTV".count)
124
+ guard let commaIndex = numericPart.firstIndex(of: ",") else { return false }
125
+ guard let generation = Int(numericPart[numericPart.startIndex..<commaIndex]) else {
126
+ return false
127
+ }
128
+ return generation >= 14
129
+ }
130
+#endif
131
+
132
@MainActor
133
final class OndeGuideEngine: ObservableObject {
134
@Published private(set) var availability: PrivateGuideAvailability = .notInstalled
@@ -121,20 +160,43 @@ final class OndeGuideEngine: ObservableObject {
160
}
161
162
switch availability {
124
- case .ready, .preparing, .answering:
163
+ case .ready, .preparing, .answering, .unsupported:
164
return
165
case .notInstalled, .downloaded, .failed:
166
break
167
}
168
169
+ #if os(tvOS)
170
+ if !kleponDeviceSupportsInference() {
171
+ availability = .unsupported(
172
+ "The private guide requires Apple TV 4K 3rd generation (2022) or newer. "
173
+ + "Older models don't have the hardware needed to run on-device inference."
174
+ )
175
+ return
176
+ }
177
+ #endif
178
+
179
availability = .preparing
180
OndeEnvironmentBootstrap.configureIfNeeded()
181
182
let engine = getOrCreateEngine()
183
184
do {
136
- _ = try await engine.loadGgufModel(
137
- config: qwen2515bConfig(),
185
+ guard
186
+ let appId = Bundle.main.object(forInfoDictionaryKey: "OndeAppId") as? String,
187
+ let appSecret = Bundle.main.object(forInfoDictionaryKey: "OndeAppSecret")
188
+ as? String,
189
+ !appId.isEmpty,
190
+ !appSecret.isEmpty
191
+ else {
192
+ availability = .failed(
193
+ "Onde credentials not configured. See Configs/Secrets.xcconfig.")
194
+ return
195
+ }
196
+
197
+ _ = try await engine.loadAssignedModel(
198
+ appId: appId,
199
+ appSecret: appSecret,
200
systemPrompt:
201
"You are Klepon, a warm and careful guide to Indonesian food. Stay grounded in the notes you are given, keep answers short, and say clearly when the guide does not have enough detail.",
202
sampling: nil
Klepon/App/AppRootView.swift
+2
@@ -88,6 +88,7 @@ struct AppRootView: View {
88
.task {
89
showingOnboarding = !appState.hasCompletedOnboarding
90
appState.guideEngine.refreshStorageUsage()
91
+ await appState.accountStore.restoreSessionIfNeeded()
92
}
93
.onChange(of: appState.hasCompletedOnboarding) { _, newValue in
94
showingOnboarding = !newValue
@@ -122,6 +123,7 @@ struct AppRootView: View {
123
.task {
124
showingOnboarding = !appState.hasCompletedOnboarding
125
appState.guideEngine.refreshStorageUsage()
126
+ await appState.accountStore.restoreSessionIfNeeded()
127
}
128
.onChange(of: appState.hasCompletedOnboarding) { _, newValue in
129
showingOnboarding = !newValue
Klepon/App/AppState.swift
+3
@@ -16,6 +16,7 @@ final class AppState: ObservableObject {
16
let recentlyViewedStore: RecentlyViewedStore
17
let guideEngine: OndeGuideEngine
18
let answerService: GuideAnswerService
19
+ let accountStore: AccountStore
20
21
init() {
22
OndeEnvironmentBootstrap.configureIfNeeded()
@@ -25,6 +26,7 @@ final class AppState: ObservableObject {
26
let recentSearchStore = RecentSearchStore()
27
let recentlyViewedStore = RecentlyViewedStore()
28
let guideEngine = OndeGuideEngine()
29
+ let accountStore = AccountStore()
30
31
self.contentRepository = repository
32
self.searchService = SearchService(repository: repository)
@@ -33,6 +35,7 @@ final class AppState: ObservableObject {
35
self.recentlyViewedStore = recentlyViewedStore
36
self.guideEngine = guideEngine
37
self.answerService = GuideAnswerService(repository: repository, guideEngine: guideEngine)
38
+ self.accountStore = accountStore
39
guideEngine.refreshStorageUsage()
40
self.hasCompletedOnboarding = UserDefaults.standard.bool(
41
forKey: Keys.hasCompletedOnboarding)
Klepon/App/KleponApp.swift
+1
@@ -12,6 +12,7 @@ struct KleponApp: App {
12
.environmentObject(appState.recentSearchStore)
13
.environmentObject(appState.recentlyViewedStore)
14
.environmentObject(appState.guideEngine)
15
+ .environmentObject(appState.accountStore)
16
#if os(iOS)
17
.preferredColorScheme(
18
UIDevice.current.userInterfaceIdiom == .pad ? nil : .light)
Klepon/App/PlatformViewSupport.swift
+12
@@ -49,6 +49,18 @@ extension View {
49
#endif
50
}
51
52
+ /// Make non-interactive content focusable on tvOS so the focus engine
53
+ /// can navigate through it and drive ScrollView scrolling.
54
+ /// No-op on all other platforms.
55
+ @ViewBuilder
56
+ func tvOSFocusable() -> some View {
57
+ #if os(tvOS)
58
+ self.focusable()
59
+ #else
60
+ self
61
+ #endif
62
+ }
63
+
64
@ViewBuilder
65
func kleponOnboardingPresentation<Content: View>(
66
isPresented: Binding<Bool>,
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
+2
-2
@@ -6,7 +6,7 @@
6
}
7
],
8
"info" : {
9
- "author" : "xcode",
9
+ "author" : "ai.splitfire.klepon",
10
"version" : 1
11
}
12
-}
12
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Back.imagestacklayer/Contents.json
+2
-2
@@ -1,6 +1,6 @@
1
{
2
"info" : {
3
- "author" : "xcode",
3
+ "author" : "ai.splitfire.klepon",
4
"version" : 1
5
}
6
-}
6
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Contents.json
+2
-2
@@ -1,6 +1,6 @@
1
{
2
"info" : {
3
- "author" : "xcode",
3
+ "author" : "ai.splitfire.klepon",
4
"version" : 1
5
},
6
"layers" : [
@@ -11,4 +11,4 @@
11
"filename" : "Back.imagestacklayer"
12
}
13
]
14
-}
14
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
+2
-2
@@ -6,7 +6,7 @@
6
}
7
],
8
"info" : {
9
- "author" : "xcode",
9
+ "author" : "ai.splitfire.klepon",
10
"version" : 1
11
}
12
-}
12
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS - App Store.imagestack/Front.imagestacklayer/Contents.json
+2
-2
@@ -1,6 +1,6 @@
1
{
2
"info" : {
3
- "author" : "xcode",
3
+ "author" : "ai.splitfire.klepon",
4
"version" : 1
5
}
6
-}
6
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Back.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Back.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
+2
-2
@@ -12,7 +12,7 @@
12
}
13
],
14
"info" : {
15
- "author" : "xcode",
15
+ "author" : "ai.splitfire.klepon",
16
"version" : 1
17
}
18
-}
18
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Back.imagestacklayer/Contents.json
+2
-2
@@ -1,6 +1,6 @@
1
{
2
"info" : {
3
- "author" : "xcode",
3
+ "author" : "ai.splitfire.klepon",
4
"version" : 1
5
},
6
"properties" : {
@@ -9,4 +9,4 @@
9
"width" : 400
10
}
11
}
12
-}
12
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Contents.json
+2
-2
@@ -1,6 +1,6 @@
1
{
2
"info" : {
3
- "author" : "xcode",
3
+ "author" : "ai.splitfire.klepon",
4
"version" : 1
5
},
6
"layers" : [
@@ -11,4 +11,4 @@
11
"filename" : "Back.imagestacklayer"
12
}
13
]
14
-}
14
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
+2
-2
@@ -12,7 +12,7 @@
12
}
13
],
14
"info" : {
15
- "author" : "xcode",
15
+ "author" : "ai.splitfire.klepon",
16
"version" : 1
17
}
18
-}
18
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Front.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Front.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/App Icon - tvOS.imagestack/Front.imagestacklayer/Contents.json
+2
-2
@@ -1,6 +1,6 @@
1
{
2
"info" : {
3
- "author" : "xcode",
3
+ "author" : "ai.splitfire.klepon",
4
"version" : 1
5
},
6
"properties" : {
@@ -9,4 +9,4 @@
9
"width" : 400
10
}
11
}
12
-}
12
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/Contents.json
+2
-2
@@ -26,7 +26,7 @@
26
}
27
],
28
"info" : {
29
- "author" : "xcode",
29
+ "author" : "ai.splitfire.klepon",
30
"version" : 1
31
}
32
-}
32
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/Contents.json
+2
-2
@@ -12,7 +12,7 @@
12
}
13
],
14
"info" : {
15
- "author" : "xcode",
15
+ "author" : "ai.splitfire.klepon",
16
"version" : 1
17
}
18
-}
18
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/TopShelfImageWide.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/TopShelfImageWide.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/TopShelfImageWide.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/TopShelfImageWide@2x.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/TopShelfImageWide@2x.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image Wide.imageset/TopShelfImageWide@2x.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/Contents.json
+2
-2
@@ -12,7 +12,7 @@
12
}
13
],
14
"info" : {
15
- "author" : "xcode",
15
+ "author" : "ai.splitfire.klepon",
16
"version" : 1
17
}
18
-}
18
+}
\ No newline at end of file
Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/TopShelfImage.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/TopShelfImage.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/TopShelfImage.png differ
Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/TopShelfImage@2x.png
Binary files a/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/TopShelfImage@2x.png and b/Klepon/Assets.xcassets/AppIconTV.brandassets/Top Shelf Image.imageset/TopShelfImage@2x.png differ
Klepon/DesignSystem/SectionHeader.swift
+17
@@ -24,3 +24,20 @@ struct SectionHeader: View {
24
.frame(maxWidth: .infinity, alignment: .leading)
25
}
26
}
27
+
28
+struct TVPageHeader: View {
29
+ let title: String
30
+
31
+ init(_ title: String) {
32
+ self.title = title
33
+ }
34
+
35
+ var body: some View {
36
+ Text(title)
37
+ .font(KleponTypography.screenTitle)
38
+ .foregroundStyle(KleponColor.textPrimary)
39
+ .frame(maxWidth: .infinity, alignment: .center)
40
+ .padding(.top, 4)
41
+ .padding(.bottom, 8)
42
+ }
43
+}
Klepon/Features/Detail/GuideDetailView.swift
+30
-4
@@ -8,6 +8,7 @@ struct GuideDetailView: View {
8
@EnvironmentObject private var recentlyViewedStore: RecentlyViewedStore
9
10
@State private var showingAskSheet = false
11
+ @State private var showingUnsupportedAlert = false
12
@State private var initialQuestion: String?
13
14
private var relatedEntries: [GuideEntry] {
@@ -18,7 +19,9 @@ struct GuideDetailView: View {
19
ScrollView {
20
VStack(alignment: .leading, spacing: 20) {
21
headerCard
22
+ .tvOSFocusable()
23
richTextSection(title: "What it is", text: entry.story)
24
+ .tvOSFocusable()
25
26
if !entry.tasteNotes.isEmpty {
27
VStack(alignment: .leading, spacing: 12) {
@@ -26,6 +29,7 @@ struct GuideDetailView: View {
29
30
FlexibleTagLayout(items: entry.tasteNotes)
31
}
32
+ .tvOSFocusable()
33
}
34
35
if !entry.highlights.isEmpty {
@@ -43,10 +47,12 @@ struct GuideDetailView: View {
47
}
48
}
49
}
50
+ .tvOSFocusable()
51
}
52
53
if let region = entry.region {
54
richTextSection(title: "Region or context", text: region)
55
+ .tvOSFocusable()
56
}
57
58
askSection
@@ -106,11 +112,33 @@ struct GuideDetailView: View {
112
AskSheetView(entry: entry, initialQuestion: initialQuestion)
113
}
114
}
115
+ .alert(
116
+ "Not available on this device",
117
+ isPresented: $showingUnsupportedAlert
118
+ ) {
119
+ Button("OK", role: .cancel) {}
120
+ } message: {
121
+ Text(
122
+ "The private guide requires Apple TV 4K 3rd generation (2022) or newer. "
123
+ + "Older models don't have the hardware needed to run on-device inference."
124
+ )
125
+ }
126
.onAppear {
127
recentlyViewedStore.record(entry.id)
128
}
129
}
130
131
+ private func openAskSheet(question: String? = nil) {
132
+ #if os(tvOS)
133
+ if !kleponDeviceSupportsInference() {
134
+ showingUnsupportedAlert = true
135
+ return
136
+ }
137
+ #endif
138
+ initialQuestion = question
139
+ showingAskSheet = true
140
+ }
141
+
142
private var headerCard: some View {
143
KleponCard(padding: 20) {
144
VStack(alignment: .leading, spacing: 16) {
@@ -170,8 +198,7 @@ struct GuideDetailView: View {
198
ForEach(Array(entry.suggestedQuestions.prefix(3)), id: \.self) {
199
question in
200
Button {
173
- initialQuestion = question
174
- showingAskSheet = true
201
+ openAskSheet(question: question)
202
} label: {
203
Text(question)
204
.font(KleponTypography.bodySecondary)
@@ -191,8 +218,7 @@ struct GuideDetailView: View {
218
}
219
220
KleponActionButton(title: "Ask privately", systemImage: "lock") {
194
- initialQuestion = nil
195
- showingAskSheet = true
221
+ openAskSheet()
222
}
223
}
224
}
Klepon/Features/Discover/DiscoverView.swift
+29
-7
@@ -7,6 +7,10 @@ struct DiscoverView: View {
7
8
@Binding var showingSettings: Bool
9
10
+ #if os(iOS)
11
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
12
+ #endif
13
+
14
private var featuredEntry: GuideEntry? {
15
let featuredEntries = appState.contentRepository.featuredEntries
16
guard !featuredEntries.isEmpty else { return nil }
@@ -26,9 +30,21 @@ struct DiscoverView: View {
30
recentlyViewedStore.entryIDs.compactMap { appState.contentRepository.entry(id: $0) }
31
}
32
33
+ private var showsSettingsButton: Bool {
34
+ #if os(iOS)
35
+ return horizontalSizeClass != .regular
36
+ #else
37
+ return true
38
+ #endif
39
+ }
40
+
41
var body: some View {
42
ScrollView {
43
VStack(alignment: .leading, spacing: 24) {
44
+ #if os(tvOS)
45
+ TVPageHeader("Klepon")
46
+ #endif
47
+
48
introSection
49
50
if let featuredEntry {
@@ -104,16 +120,22 @@ struct DiscoverView: View {
120
}
121
.background(KleponColor.background.ignoresSafeArea())
122
.navigationTitle("Klepon")
107
- .kleponLargeNavigationTitle()
123
+ #if os(tvOS)
124
+ .toolbar(.hidden, for: .navigationBar)
125
+ #else
126
+ .kleponLargeNavigationTitle()
127
+ #endif
128
#if !os(tvOS)
129
.toolbar {
110
- ToolbarItem(placement: .kleponPrimaryAction) {
111
- Button {
112
- showingSettings = true
113
- } label: {
114
- Image(systemName: "gearshape.2")
130
+ if showsSettingsButton {
131
+ ToolbarItem(placement: .kleponPrimaryAction) {
132
+ Button {
133
+ showingSettings = true
134
+ } label: {
135
+ Image(systemName: "gearshape.2")
136
+ }
137
+ .tint(KleponColor.accent)
138
}
116
- .tint(KleponColor.accent)
139
}
140
}
141
#endif
Klepon/Features/Onboarding/OnboardingView.swift
+2
@@ -14,6 +14,8 @@ struct OnboardingView: View {
14
return "Finish preparing private guide"
15
case .failed:
16
return "Try private guide again"
17
+ case .unsupported:
18
+ return "Not available on this device"
19
default:
20
return "Add private guide"
21
}
Klepon/Features/Saved/SavedView.swift
+7
@@ -11,6 +11,10 @@ struct SavedView: View {
11
var body: some View {
12
ScrollView {
13
VStack(alignment: .leading, spacing: 20) {
14
+ #if os(tvOS)
15
+ TVPageHeader("Saved")
16
+ #endif
17
+
18
if savedEntries.isEmpty {
19
EmptyStateView(
20
title: "Save dishes you want to revisit",
@@ -61,5 +65,8 @@ struct SavedView: View {
65
}
66
.background(KleponColor.background.ignoresSafeArea())
67
.navigationTitle("Saved")
68
+ #if os(tvOS)
69
+ .toolbar(.hidden, for: .navigationBar)
70
+ #endif
71
}
72
}
Klepon/Features/Search/SearchView.swift
+55
-4
@@ -19,6 +19,11 @@ struct SearchView: View {
19
var body: some View {
20
ScrollView {
21
VStack(alignment: .leading, spacing: 20) {
22
+ #if os(tvOS)
23
+ TVPageHeader("Search")
24
+ tvSearchField
25
+ #endif
26
+
27
if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
28
if !recentSearchStore.queries.isEmpty {
29
VStack(alignment: .leading, spacing: 12) {
@@ -103,11 +108,57 @@ struct SearchView: View {
108
}
109
.background(KleponColor.background.ignoresSafeArea())
110
.navigationTitle("Search")
106
- .searchable(text: $query, prompt: "Try rendang, sambal, or klepon")
107
- .onSubmit(of: .search) {
108
- appState.recentSearchStore.record(query)
109
- }
111
+ #if os(tvOS)
112
+ .toolbar(.hidden, for: .navigationBar)
113
+ #else
114
+ .searchable(text: $query, prompt: "Try rendang, sambal, or klepon")
115
+ .onSubmit(of: .search) {
116
+ appState.recentSearchStore.record(query)
117
+ }
118
+ #endif
119
}
120
+
121
+ #if os(tvOS)
122
+ private var tvSearchField: some View {
123
+ KleponCard(padding: 18) {
124
+ HStack(spacing: 12) {
125
+ Image(systemName: "magnifyingglass")
126
+ .font(.title3)
127
+ .foregroundStyle(KleponColor.accentWarm)
128
+
129
+ TextField("Try rendang, sambal, or klepon", text: $query)
130
+ .font(KleponTypography.body)
131
+ .foregroundColor(KleponColor.textPrimary)
132
+ .textFieldStyle(.plain)
133
+ .submitLabel(.search)
134
+ .onSubmit {
135
+ appState.recentSearchStore.record(query)
136
+ }
137
+
138
+ if !query.isEmpty {
139
+ Button {
140
+ query = ""
141
+ } label: {
142
+ Image(systemName: "xmark.circle.fill")
143
+ .font(.title3)
144
+ .foregroundStyle(KleponColor.textSecondary)
145
+ }
146
+ .buttonStyle(.plain)
147
+ }
148
+ }
149
+ .padding(.horizontal, 16)
150
+ .padding(.vertical, 14)
151
+ .background(
152
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
153
+ .fill(KleponColor.surface)
154
+ )
155
+ .overlay(
156
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
157
+ .stroke(KleponColor.divider, lineWidth: 1)
158
+ )
159
+ }
160
+ }
161
+ #endif
162
}
163
164
private struct SearchResultCard: View {
Klepon/Features/Settings/SettingsView.swift
+27
@@ -6,6 +6,7 @@ struct SettingsView: View {
6
@EnvironmentObject private var favoritesStore: FavoritesStore
7
@EnvironmentObject private var recentSearchStore: RecentSearchStore
8
@EnvironmentObject private var guideEngine: OndeGuideEngine
9
+ @EnvironmentObject private var accountStore: AccountStore
10
11
private var versionString: String {
12
let version =
@@ -18,6 +19,29 @@ struct SettingsView: View {
19
var body: some View {
20
ScrollView {
21
VStack(alignment: .leading, spacing: 18) {
22
+ #if os(tvOS)
23
+ TVPageHeader("Settings")
24
+ #endif
25
+
26
+ #if os(macOS)
27
+ HStack {
28
+ Spacer()
29
+
30
+ Button("Close") {
31
+ dismiss()
32
+ }
33
+ .font(KleponTypography.bodySecondary.weight(.semibold))
34
+ .foregroundStyle(KleponColor.accent)
35
+ .keyboardShortcut(.cancelAction)
36
+ }
37
+ #endif
38
+
39
+ #if DEBUG
40
+ DebugSettingsCard()
41
+ #endif
42
+
43
+ AccountSettingsCard()
44
+
45
KleponCard {
46
VStack(alignment: .leading, spacing: 12) {
47
Text("Private guide")
@@ -170,6 +194,9 @@ struct SettingsView: View {
194
}
195
.background(KleponColor.background.ignoresSafeArea())
196
.navigationTitle("Settings")
197
+ #if os(tvOS)
198
+ .toolbar(.hidden, for: .navigationBar)
199
+ #endif
200
#if !os(tvOS)
201
.toolbar {
202
ToolbarItem(placement: .kleponPrimaryAction) {
Klepon/Info.plist
+14
-5
@@ -1,5 +1,6 @@
1
-<?xml version="1.0" encoding="UTF-8"?>
2
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1
+<?xml version="1.0" encoding="UTF-8" ?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
<plist version="1.0">
5
<dict>
6
<key>CFBundleDevelopmentRegion</key>
@@ -19,11 +20,19 @@
20
<key>CFBundleVersion</key>
21
<string>$(CURRENT_PROJECT_VERSION)</string>
22
<key>ITSAppUsesNonExemptEncryption</key>
22
- <false/>
23
+ <false />
24
+ <key>OndeAppId</key>
25
+ <string>$(ONDE_APP_ID)</string>
26
+ <key>OndeAppSecret</key>
27
+ <string>$(ONDE_APP_SECRET)</string>
28
+ <key>OndeWebDevHost</key>
29
+ <string>$(ONDE_WEB_DEV_HOST)</string>
30
+ <key>OndeWebDevPort</key>
31
+ <string>$(ONDE_WEB_DEV_PORT)</string>
32
<key>UIApplicationSceneManifest</key>
33
<dict>
34
<key>UIApplicationSupportsMultipleScenes</key>
26
- <false/>
35
+ <false />
36
<key>UISceneConfigurations</key>
37
<dict>
38
<key>UIWindowSceneSessionRoleApplication</key>
@@ -38,7 +47,7 @@
47
</dict>
48
</dict>
49
<key>UILaunchScreen</key>
41
- <dict/>
50
+ <dict />
51
<key>UISupportedInterfaceOrientations</key>
52
<array>
53
<string>UIInterfaceOrientationPortrait</string>
Klepon/Shared/Account/AccountStore.swift
new
+589
@@ -0,0 +1,589 @@
1
+import Foundation
2
+
3
+struct AccountUser: Decodable, Equatable, Hashable, Sendable {
4
+ let id: Int
5
+ let email: String
6
+ let name: String?
7
+ let username: String?
8
+ let createdAt: String
9
+ let updatedAt: String
10
+ let passwordRulesSummary: String?
11
+
12
+ var displayName: String {
13
+ let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
14
+ if let trimmedName, !trimmedName.isEmpty {
15
+ return trimmedName
16
+ }
17
+
18
+ return email
19
+ }
20
+
21
+ var preferredTitle: String {
22
+ let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
23
+ if let trimmedName, !trimmedName.isEmpty {
24
+ return trimmedName
25
+ }
26
+
27
+ if let usernameDisplay {
28
+ return usernameDisplay
29
+ }
30
+
31
+ return email
32
+ }
33
+
34
+ var usernameDisplay: String? {
35
+ let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines)
36
+ guard let trimmedUsername, !trimmedUsername.isEmpty else { return nil }
37
+ return "@\(trimmedUsername)"
38
+ }
39
+}
40
+
41
+struct AccountSignupResult: Decodable, Equatable, Hashable, Sendable {
42
+ let code: Int?
43
+ let message: String
44
+ let userId: Int?
45
+ let userEmail: String?
46
+ let userCreatedAt: String?
47
+}
48
+
49
+private enum AccountLoginStatus: Decodable, Sendable {
50
+ case notFound
51
+ case ready(accessToken: String)
52
+ case incomplete(errorCode: UInt32)
53
+
54
+ private enum CodingKeys: String, CodingKey {
55
+ case status
56
+ case accessToken
57
+ case errorCode
58
+ }
59
+
60
+ init(from decoder: Decoder) throws {
61
+ let container = try decoder.container(keyedBy: CodingKeys.self)
62
+ let status = try container.decode(String.self, forKey: .status)
63
+
64
+ switch status {
65
+ case "notFound":
66
+ self = .notFound
67
+ case "ready":
68
+ self = .ready(accessToken: try container.decode(String.self, forKey: .accessToken))
69
+ case "incomplete":
70
+ self = .incomplete(errorCode: try container.decode(UInt32.self, forKey: .errorCode))
71
+ default:
72
+ throw DecodingError.dataCorruptedError(
73
+ forKey: .status,
74
+ in: container,
75
+ debugDescription: "Unsupported login status \(status)"
76
+ )
77
+ }
78
+ }
79
+}
80
+
81
+private struct AccountProxyError: Decodable {
82
+ let errorCode: Int?
83
+ let message: String?
84
+ let error: String?
85
+}
86
+
87
+private struct AccountActionMessage: Decodable {
88
+ let message: String
89
+}
90
+
91
+enum AccountProxyEnvironment: String, CaseIterable, Identifiable {
92
+ case development
93
+ case production
94
+
95
+ var id: String { rawValue }
96
+
97
+ var displayName: String {
98
+ switch self {
99
+ case .development: return "Development"
100
+ case .production: return "Production"
101
+ }
102
+ }
103
+
104
+ var baseURL: URL {
105
+ switch self {
106
+ case .development:
107
+ let host = Bundle.main.object(forInfoDictionaryKey: "OndeWebDevHost") as? String
108
+ let port = Bundle.main.object(forInfoDictionaryKey: "OndeWebDevPort") as? String
109
+ let resolvedHost = (host?.isEmpty == false) ? host! : "localhost"
110
+ let resolvedPort = (port?.isEmpty == false) ? port! : "3000"
111
+ return URL(string: "http://\(resolvedHost):\(resolvedPort)")!
112
+ case .production:
113
+ return URL(string: "https://ondeinference.com")!
114
+ }
115
+ }
116
+
117
+ var badgeColor: String {
118
+ switch self {
119
+ case .development: return "orange"
120
+ case .production: return "green"
121
+ }
122
+ }
123
+}
124
+
125
+@MainActor
126
+final class AccountStore: ObservableObject {
127
+ @Published private(set) var currentUser: AccountUser?
128
+ @Published private(set) var accessToken: String?
129
+ @Published private(set) var isBusy = false
130
+ @Published var notice: String?
131
+ @Published var errorMessage: String?
132
+
133
+ @Published private(set) var proxyEnvironment: AccountProxyEnvironment
134
+
135
+ private let userDefaults: UserDefaults
136
+ private let session: URLSession
137
+ private let decoder: JSONDecoder
138
+ private var gatewayBaseURL: URL
139
+ private var hasAttemptedRestore = false
140
+
141
+ init(
142
+ userDefaults: UserDefaults = .standard,
143
+ session: URLSession = .shared
144
+ ) {
145
+ let savedEnvironmentRaw = userDefaults.string(forKey: Keys.proxyEnvironment)
146
+ let savedEnvironment =
147
+ savedEnvironmentRaw.flatMap(AccountProxyEnvironment.init(rawValue:)) ?? .production
148
+
149
+ self.userDefaults = userDefaults
150
+ self.session = session
151
+ self.proxyEnvironment = savedEnvironment
152
+ self.gatewayBaseURL = savedEnvironment.baseURL
153
+ self.accessToken = userDefaults.string(forKey: Keys.accessToken)
154
+ self.currentUser = nil
155
+
156
+ let decoder = JSONDecoder()
157
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
158
+ self.decoder = decoder
159
+ }
160
+
161
+ /// Switches the active proxy environment, signs out, and persists the choice.
162
+ func switchEnvironment(_ environment: AccountProxyEnvironment) {
163
+ guard environment != proxyEnvironment else { return }
164
+ proxyEnvironment = environment
165
+ gatewayBaseURL = environment.baseURL
166
+ userDefaults.set(environment.rawValue, forKey: Keys.proxyEnvironment)
167
+ hasAttemptedRestore = false
168
+ signOutLocally()
169
+ notice = "Switched to \(environment.displayName). Sign in again."
170
+ }
171
+
172
+ var isSignedIn: Bool {
173
+ currentUser != nil && accessToken != nil
174
+ }
175
+
176
+ var statusTitle: String {
177
+ if let currentUser {
178
+ return currentUser.preferredTitle
179
+ }
180
+
181
+ return "Signed out"
182
+ }
183
+
184
+ var statusDetail: String {
185
+ if currentUser != nil {
186
+ return "Your account is ready on this device."
187
+ }
188
+
189
+ return "Sign in or create an account to keep your profile ready in Klepon."
190
+ }
191
+
192
+ func restoreSessionIfNeeded() async {
193
+ guard !hasAttemptedRestore else { return }
194
+ hasAttemptedRestore = true
195
+
196
+ guard accessToken != nil else { return }
197
+
198
+ await refreshProfile(showNoticeOnSuccess: false)
199
+ }
200
+
201
+ @discardableResult
202
+ func login(email: String, password: String) async -> Bool {
203
+ isBusy = true
204
+ clearTransientMessages()
205
+ defer { isBusy = false }
206
+
207
+ do {
208
+ let status: AccountLoginStatus = try await requestJSON(
209
+ path: "api/auth/sign-in",
210
+ method: "POST",
211
+ payload: [
212
+ "email": normalized(email),
213
+ "password": password,
214
+ ]
215
+ )
216
+
217
+ switch status {
218
+ case .ready(let accessToken):
219
+ self.accessToken = accessToken
220
+ userDefaults.set(accessToken, forKey: Keys.accessToken)
221
+ return await refreshProfile(showNoticeOnSuccess: true)
222
+
223
+ case .notFound:
224
+ errorMessage = "We could not find an account for that email address."
225
+ return false
226
+
227
+ case .incomplete:
228
+ errorMessage =
229
+ "Your account isn’t ready yet. Confirm your email first, or resend the confirmation email below."
230
+ return false
231
+ }
232
+ } catch {
233
+ errorMessage = describe(error)
234
+ return false
235
+ }
236
+ }
237
+
238
+ @discardableResult
239
+ func signUp(email: String, password: String) async -> AccountSignupResult? {
240
+ isBusy = true
241
+ clearTransientMessages()
242
+ defer { isBusy = false }
243
+
244
+ do {
245
+ let result: AccountSignupResult = try await requestJSON(
246
+ path: "api/auth/sign-up",
247
+ method: "POST",
248
+ payload: [
249
+ "email": normalized(email),
250
+ "password": password,
251
+ ]
252
+ )
253
+ notice = result.message
254
+ return result
255
+ } catch {
256
+ errorMessage = describe(error)
257
+ return nil
258
+ }
259
+ }
260
+
261
+ @discardableResult
262
+ func resendConfirmation(email: String) async -> Bool {
263
+ let normalizedEmail = normalized(email)
264
+ guard !normalizedEmail.isEmpty else {
265
+ errorMessage = "Enter your email first."
266
+ return false
267
+ }
268
+
269
+ isBusy = true
270
+ clearTransientMessages()
271
+ defer { isBusy = false }
272
+
273
+ do {
274
+ let response: AccountActionMessage = try await requestJSON(
275
+ path: "api/auth/resend-confirmation",
276
+ method: "POST",
277
+ payload: [
278
+ "email": normalizedEmail
279
+ ]
280
+ )
281
+ notice = response.message
282
+ return true
283
+ } catch {
284
+ errorMessage = describe(error)
285
+ return false
286
+ }
287
+ }
288
+
289
+ @discardableResult
290
+ func requestPasswordReset(email: String) async -> Bool {
291
+ let normalizedEmail = normalized(email)
292
+ guard !normalizedEmail.isEmpty else {
293
+ errorMessage = "Enter your email first."
294
+ return false
295
+ }
296
+
297
+ isBusy = true
298
+ clearTransientMessages()
299
+ defer { isBusy = false }
300
+
301
+ do {
302
+ let response: AccountActionMessage = try await requestJSON(
303
+ path: "api/auth/reset-password",
304
+ method: "POST",
305
+ payload: [
306
+ "email": normalizedEmail
307
+ ]
308
+ )
309
+ notice = response.message
310
+ return true
311
+ } catch {
312
+ errorMessage = describe(error)
313
+ return false
314
+ }
315
+ }
316
+
317
+ @discardableResult
318
+ func updateProfile(name: String, username: String) async -> Bool {
319
+ guard let accessToken else {
320
+ errorMessage = "Sign in before updating your profile."
321
+ return false
322
+ }
323
+
324
+ isBusy = true
325
+ clearTransientMessages()
326
+ defer { isBusy = false }
327
+
328
+ do {
329
+ currentUser = try await requestJSON(
330
+ path: "api/auth/me",
331
+ method: "PATCH",
332
+ accessToken: accessToken,
333
+ payload: [
334
+ "name": normalizedProfileField(name),
335
+ "username": normalizedUsername(username),
336
+ ]
337
+ )
338
+ notice = "Profile updated."
339
+ return true
340
+ } catch {
341
+ errorMessage = describe(error)
342
+ return false
343
+ }
344
+ }
345
+
346
+ @discardableResult
347
+ func updatePassword(
348
+ currentPassword: String,
349
+ newPassword: String,
350
+ passwordConfirmation: String
351
+ ) async -> Bool {
352
+ guard let accessToken else {
353
+ errorMessage = "Sign in before updating your password."
354
+ return false
355
+ }
356
+
357
+ isBusy = true
358
+ clearTransientMessages()
359
+ defer { isBusy = false }
360
+
361
+ do {
362
+ let response: AccountActionMessage = try await requestJSON(
363
+ path: "api/auth/password",
364
+ method: "PATCH",
365
+ accessToken: accessToken,
366
+ payload: [
367
+ "currentPassword": currentPassword,
368
+ "password": newPassword,
369
+ "passwordConfirmation": passwordConfirmation,
370
+ ]
371
+ )
372
+ notice = response.message
373
+ return true
374
+ } catch {
375
+ errorMessage = describe(error)
376
+ return false
377
+ }
378
+ }
379
+
380
+ @discardableResult
381
+ func refreshProfile(showNoticeOnSuccess: Bool = true) async -> Bool {
382
+ guard let accessToken else { return false }
383
+
384
+ isBusy = true
385
+ if !showNoticeOnSuccess {
386
+ errorMessage = nil
387
+ } else {
388
+ clearTransientMessages()
389
+ }
390
+ defer { isBusy = false }
391
+
392
+ do {
393
+ currentUser = try await requestJSON(
394
+ path: "api/auth/me",
395
+ method: "GET",
396
+ accessToken: accessToken
397
+ )
398
+ if showNoticeOnSuccess, let currentUser {
399
+ notice = "Signed in as \(currentUser.email)."
400
+ }
401
+ return true
402
+ } catch {
403
+ signOutLocally()
404
+ errorMessage = describe(error)
405
+ return false
406
+ }
407
+ }
408
+
409
+ func logout() async {
410
+ guard let accessToken else {
411
+ signOutLocally()
412
+ return
413
+ }
414
+
415
+ isBusy = true
416
+ clearTransientMessages()
417
+ defer { isBusy = false }
418
+
419
+ do {
420
+ try await requestVoid(
421
+ path: "api/auth/sign-out",
422
+ method: "POST",
423
+ accessToken: accessToken
424
+ )
425
+ signOutLocally()
426
+ notice = "Signed out."
427
+ } catch {
428
+ signOutLocally()
429
+ errorMessage = describe(error)
430
+ }
431
+ }
432
+
433
+ func removeAccount() async {
434
+ guard let accessToken else {
435
+ errorMessage = "Sign in before removing the account."
436
+ return
437
+ }
438
+
439
+ isBusy = true
440
+ clearTransientMessages()
441
+ defer { isBusy = false }
442
+
443
+ do {
444
+ try await requestVoid(
445
+ path: "api/auth/account",
446
+ method: "DELETE",
447
+ accessToken: accessToken
448
+ )
449
+ signOutLocally()
450
+ notice = "Account removed."
451
+ } catch {
452
+ errorMessage = describe(error)
453
+ }
454
+ }
455
+
456
+ private func requestVoid(
457
+ path: String,
458
+ method: String,
459
+ accessToken: String? = nil,
460
+ payload: [String: Any]? = nil
461
+ ) async throws {
462
+ let request = try makeRequest(
463
+ path: path,
464
+ method: method,
465
+ accessToken: accessToken,
466
+ payload: payload
467
+ )
468
+ let (data, response) = try await session.data(for: request)
469
+ try validate(response: response, data: data)
470
+ }
471
+
472
+ private func requestJSON<Response: Decodable>(
473
+ path: String,
474
+ method: String,
475
+ accessToken: String? = nil,
476
+ payload: [String: Any]? = nil
477
+ ) async throws -> Response {
478
+ let request = try makeRequest(
479
+ path: path,
480
+ method: method,
481
+ accessToken: accessToken,
482
+ payload: payload
483
+ )
484
+ let (data, response) = try await session.data(for: request)
485
+ try validate(response: response, data: data)
486
+ return try decoder.decode(Response.self, from: data)
487
+ }
488
+
489
+ private func makeRequest(
490
+ path: String,
491
+ method: String,
492
+ accessToken: String?,
493
+ payload: [String: Any]?
494
+ ) throws -> URLRequest {
495
+ var components = URLComponents(
496
+ url: gatewayBaseURL.appendingPathComponent(path),
497
+ resolvingAgainstBaseURL: false
498
+ )
499
+ components?.queryItems = [
500
+ URLQueryItem(name: "env", value: proxyEnvironment.rawValue)
501
+ ]
502
+
503
+ guard let url = components?.url else {
504
+ throw URLError(.badURL)
505
+ }
506
+
507
+ var request = URLRequest(url: url)
508
+ request.httpMethod = method
509
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
510
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
511
+
512
+ if let accessToken {
513
+ request.setValue(
514
+ "Bearer \(normalizedToken(accessToken))",
515
+ forHTTPHeaderField: "Authorization"
516
+ )
517
+ }
518
+
519
+ if let payload {
520
+ request.httpBody = try JSONSerialization.data(withJSONObject: payload)
521
+ }
522
+
523
+ return request
524
+ }
525
+
526
+ private func validate(response: URLResponse, data: Data) throws {
527
+ guard let response = response as? HTTPURLResponse else {
528
+ throw URLError(.badServerResponse)
529
+ }
530
+
531
+ guard (200..<300).contains(response.statusCode) else {
532
+ throw proxyError(from: data, statusCode: response.statusCode)
533
+ }
534
+ }
535
+
536
+ private func proxyError(from data: Data, statusCode: Int) -> NSError {
537
+ let proxyError = try? decoder.decode(AccountProxyError.self, from: data)
538
+ let message = proxyError?.message ?? proxyError?.error ?? "Request failed (\(statusCode))."
539
+
540
+ return NSError(
541
+ domain: "KleponAccountProxy",
542
+ code: proxyError?.errorCode ?? statusCode,
543
+ userInfo: [NSLocalizedDescriptionKey: message]
544
+ )
545
+ }
546
+
547
+ private func signOutLocally() {
548
+ currentUser = nil
549
+ accessToken = nil
550
+ userDefaults.removeObject(forKey: Keys.accessToken)
551
+ }
552
+
553
+ private func clearTransientMessages() {
554
+ notice = nil
555
+ errorMessage = nil
556
+ }
557
+
558
+ private func normalized(_ value: String) -> String {
559
+ value.trimmingCharacters(in: .whitespacesAndNewlines)
560
+ }
561
+
562
+ private func normalizedProfileField(_ value: String) -> String {
563
+ normalized(value)
564
+ }
565
+
566
+ private func normalizedUsername(_ value: String) -> String {
567
+ normalized(value).lowercased()
568
+ }
569
+
570
+ private func normalizedToken(_ token: String) -> String {
571
+ var normalized = token.trimmingCharacters(in: .whitespacesAndNewlines)
572
+
573
+ while normalized.lowercased().hasPrefix("bearer ") {
574
+ normalized = String(normalized.dropFirst(7)).trimmingCharacters(
575
+ in: .whitespacesAndNewlines)
576
+ }
577
+
578
+ return normalized
579
+ }
580
+
581
+ private func describe(_ error: Error) -> String {
582
+ error.localizedDescription
583
+ }
584
+}
585
+
586
+private enum Keys {
587
+ static let accessToken = "klepon.account.accessToken"
588
+ static let proxyEnvironment = "klepon.account.proxyEnvironment"
589
+}
Klepon/Shared/Account/AccountView.swift
new
+793
@@ -0,0 +1,793 @@
1
+import SwiftUI
2
+
3
+private enum AccountTextContentType {
4
+ case username
5
+ case password
6
+ case newPassword
7
+}
8
+
9
+#if os(iOS) || os(tvOS) || os(visionOS)
10
+ import UIKit
11
+ typealias AccountKeyboardType = UIKeyboardType
12
+#else
13
+ enum AccountKeyboardType {
14
+ case emailAddress
15
+ }
16
+#endif
17
+
18
+private enum AccountFormMode: String, CaseIterable, Identifiable {
19
+ case login = "Log in"
20
+ case signup = "Sign up"
21
+
22
+ var id: String { rawValue }
23
+}
24
+
25
+struct AccountView: View {
26
+ @EnvironmentObject private var accountStore: AccountStore
27
+
28
+ @State private var formMode: AccountFormMode = .login
29
+ @State private var loginEmail = ""
30
+ @State private var loginPassword = ""
31
+ @State private var signupEmail = ""
32
+ @State private var signupPassword = ""
33
+ @State private var signupPasswordConfirmation = ""
34
+ @State private var showingRemoveConfirmation = false
35
+
36
+ var body: some View {
37
+ ScrollView {
38
+ VStack(alignment: .leading, spacing: 18) {
39
+ #if os(tvOS)
40
+ TVPageHeader("Account")
41
+ #endif
42
+
43
+ SectionHeader(
44
+ "Account",
45
+ subtitle:
46
+ "Create an account, sign in, and keep your profile ready in Klepon."
47
+ )
48
+
49
+ AccountStatusBanner(
50
+ notice: accountStore.notice,
51
+ errorMessage: accountStore.errorMessage
52
+ )
53
+
54
+ AccountProxyInfoCard()
55
+
56
+ if accountStore.isSignedIn {
57
+ AccountProfileCard(
58
+ user: accountStore.currentUser,
59
+ isBusy: accountStore.isBusy,
60
+ onRefresh: {
61
+ Task {
62
+ await accountStore.refreshProfile()
63
+ }
64
+ },
65
+ onSaveProfile: { name, username in
66
+ await accountStore.updateProfile(name: name, username: username)
67
+ },
68
+ onUpdatePassword: { currentPassword, newPassword, passwordConfirmation in
69
+ await accountStore.updatePassword(
70
+ currentPassword: currentPassword,
71
+ newPassword: newPassword,
72
+ passwordConfirmation: passwordConfirmation
73
+ )
74
+ },
75
+ onLogout: {
76
+ Task {
77
+ await accountStore.logout()
78
+ }
79
+ },
80
+ onRemoveAccount: {
81
+ showingRemoveConfirmation = true
82
+ }
83
+ )
84
+ } else {
85
+ AccountModePicker(formMode: $formMode)
86
+
87
+ if formMode == .login {
88
+ AccountLoginCard(
89
+ email: $loginEmail,
90
+ password: $loginPassword,
91
+ isBusy: accountStore.isBusy,
92
+ canSubmit: !loginEmail.trimmingCharacters(in: .whitespacesAndNewlines)
93
+ .isEmpty && !loginPassword.isEmpty,
94
+ onSubmit: {
95
+ Task {
96
+ let didLogin = await accountStore.login(
97
+ email: loginEmail,
98
+ password: loginPassword
99
+ )
100
+ if didLogin {
101
+ loginPassword = ""
102
+ }
103
+ }
104
+ },
105
+ onResendConfirmation: {
106
+ Task {
107
+ await accountStore.resendConfirmation(email: loginEmail)
108
+ }
109
+ },
110
+ onForgotPassword: {
111
+ Task {
112
+ await accountStore.requestPasswordReset(email: loginEmail)
113
+ }
114
+ }
115
+ )
116
+ } else {
117
+ AccountSignupCard(
118
+ email: $signupEmail,
119
+ password: $signupPassword,
120
+ passwordConfirmation: $signupPasswordConfirmation,
121
+ isBusy: accountStore.isBusy,
122
+ canSubmit: canSubmitSignup,
123
+ onSubmit: {
124
+ Task {
125
+ let result = await accountStore.signUp(
126
+ email: signupEmail,
127
+ password: signupPassword
128
+ )
129
+
130
+ guard result != nil else { return }
131
+
132
+ loginEmail = signupEmail
133
+ loginPassword = signupPassword
134
+ signupPassword = ""
135
+ signupPasswordConfirmation = ""
136
+ formMode = .login
137
+ }
138
+ }
139
+ )
140
+ }
141
+ }
142
+ }
143
+ .padding(.horizontal, 20)
144
+ .padding(.vertical, 16)
145
+ .kleponReadableContentWidth()
146
+ }
147
+ .background(KleponColor.background.ignoresSafeArea())
148
+ .navigationTitle("Account")
149
+ #if os(tvOS)
150
+ .toolbar(.hidden, for: .navigationBar)
151
+ #else
152
+ .kleponInlineNavigationTitle()
153
+ #endif
154
+ .task {
155
+ await accountStore.restoreSessionIfNeeded()
156
+ }
157
+ .confirmationDialog(
158
+ "Delete this account?",
159
+ isPresented: $showingRemoveConfirmation,
160
+ titleVisibility: .visible
161
+ ) {
162
+ Button("Remove account", role: .destructive) {
163
+ Task {
164
+ await accountStore.removeAccount()
165
+ }
166
+ }
167
+
168
+ Button("Cancel", role: .cancel) {}
169
+ } message: {
170
+ Text(
171
+ "This permanently removes the account tied to your current session."
172
+ )
173
+ }
174
+ }
175
+
176
+ private var canSubmitSignup: Bool {
177
+ !signupEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
178
+ && !signupPassword.isEmpty
179
+ && signupPassword == signupPasswordConfirmation
180
+ }
181
+}
182
+
183
+struct AccountSettingsCard: View {
184
+ @EnvironmentObject private var accountStore: AccountStore
185
+
186
+ var body: some View {
187
+ NavigationLink {
188
+ AccountView()
189
+ } label: {
190
+ KleponCard {
191
+ VStack(alignment: .leading, spacing: 12) {
192
+ HStack(alignment: .top, spacing: 12) {
193
+ Image(
194
+ systemName: accountStore.isSignedIn
195
+ ? "person.crop.circle.badge.checkmark" : "person.crop.circle"
196
+ )
197
+ .font(.system(size: 24))
198
+ .foregroundStyle(KleponColor.accent)
199
+
200
+ VStack(alignment: .leading, spacing: 6) {
201
+ Text("Account")
202
+ .font(KleponTypography.cardTitle)
203
+ .foregroundStyle(KleponColor.textPrimary)
204
+
205
+ Text(accountStore.statusTitle)
206
+ .font(KleponTypography.bodySecondary.weight(.semibold))
207
+ .foregroundStyle(KleponColor.textPrimary)
208
+
209
+ Text(accountStore.statusDetail)
210
+ .font(KleponTypography.caption)
211
+ .foregroundStyle(KleponColor.textSecondary)
212
+ }
213
+
214
+ Spacer(minLength: 12)
215
+
216
+ Image(systemName: "chevron.right")
217
+ .font(.footnote.weight(.semibold))
218
+ .foregroundStyle(KleponColor.textSecondary)
219
+ }
220
+
221
+ HStack(spacing: 10) {
222
+ KleponChip(
223
+ title: "Email account",
224
+ icon: "person.crop.circle"
225
+ )
226
+
227
+ KleponChip(
228
+ title: accountStore.isSignedIn ? "Profile ready" : "Signed out",
229
+ icon: accountStore.isSignedIn
230
+ ? "checkmark.circle" : "rectangle.portrait.and.arrow.right"
231
+ )
232
+ }
233
+ }
234
+ }
235
+ }
236
+ .kleponInteractiveButtonStyle()
237
+ }
238
+}
239
+
240
+private struct AccountStatusBanner: View {
241
+ let notice: String?
242
+ let errorMessage: String?
243
+
244
+ var body: some View {
245
+ if let errorMessage {
246
+ KleponCard {
247
+ VStack(alignment: .leading, spacing: 8) {
248
+ Label("Something needs attention", systemImage: "exclamationmark.triangle.fill")
249
+ .font(KleponTypography.bodySecondary.weight(.semibold))
250
+ .foregroundStyle(KleponColor.highlight)
251
+
252
+ Text(errorMessage)
253
+ .font(KleponTypography.bodySecondary)
254
+ .foregroundStyle(KleponColor.textSecondary)
255
+ }
256
+ }
257
+ } else if let notice {
258
+ KleponCard {
259
+ VStack(alignment: .leading, spacing: 8) {
260
+ Label("Status", systemImage: "checkmark.circle.fill")
261
+ .font(KleponTypography.bodySecondary.weight(.semibold))
262
+ .foregroundStyle(KleponColor.accent)
263
+
264
+ Text(notice)
265
+ .font(KleponTypography.bodySecondary)
266
+ .foregroundStyle(KleponColor.textSecondary)
267
+ }
268
+ }
269
+ }
270
+ }
271
+}
272
+
273
+private struct AccountProxyInfoCard: View {
274
+ var body: some View {
275
+ KleponCard {
276
+ VStack(alignment: .leading, spacing: 12) {
277
+ Text("Private sign in")
278
+ .font(KleponTypography.cardTitle)
279
+ .foregroundStyle(KleponColor.textPrimary)
280
+
281
+ Text(
282
+ "Create your account, confirm your email, and reset your password right from Klepon."
283
+ )
284
+ .font(KleponTypography.bodySecondary)
285
+ .foregroundStyle(KleponColor.textSecondary)
286
+
287
+ HStack(spacing: 10) {
288
+ KleponChip(title: "Email sign in", icon: "envelope")
289
+ KleponChip(title: "Password reset", icon: "key")
290
+ }
291
+ }
292
+ }
293
+ }
294
+}
295
+
296
+private struct AccountModePicker: View {
297
+ @Binding var formMode: AccountFormMode
298
+
299
+ var body: some View {
300
+ HStack(spacing: 10) {
301
+ ForEach(AccountFormMode.allCases) { mode in
302
+ Button {
303
+ formMode = mode
304
+ } label: {
305
+ Text(mode.rawValue)
306
+ .font(KleponTypography.bodySecondary.weight(.semibold))
307
+ .frame(maxWidth: .infinity)
308
+ .padding(.vertical, 12)
309
+ .background(
310
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
311
+ .fill(
312
+ formMode == mode
313
+ ? KleponColor.accent : KleponColor.surfaceSecondary)
314
+ )
315
+ .foregroundStyle(formMode == mode ? Color.white : KleponColor.textPrimary)
316
+ }
317
+ .kleponInteractiveButtonStyle()
318
+ }
319
+ }
320
+ }
321
+}
322
+
323
+private struct AccountLoginCard: View {
324
+ @Binding var email: String
325
+ @Binding var password: String
326
+
327
+ let isBusy: Bool
328
+ let canSubmit: Bool
329
+ let onSubmit: () -> Void
330
+ let onResendConfirmation: () -> Void
331
+ let onForgotPassword: () -> Void
332
+
333
+ private var canRequestEmailAction: Bool {
334
+ !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
335
+ }
336
+
337
+ var body: some View {
338
+ KleponCard {
339
+ VStack(alignment: .leading, spacing: 14) {
340
+ Text("Log in")
341
+ .font(KleponTypography.cardTitle)
342
+ .foregroundStyle(KleponColor.textPrimary)
343
+
344
+ Text(
345
+ "Use your email and password to sign in, resend your confirmation email, or reset your password."
346
+ )
347
+ .font(KleponTypography.bodySecondary)
348
+ .foregroundStyle(KleponColor.textSecondary)
349
+
350
+ AccountInputField(
351
+ title: "Email",
352
+ placeholder: "you@example.com",
353
+ text: $email,
354
+ textContentType: .username,
355
+ keyboard: .emailAddress
356
+ )
357
+
358
+ AccountInputField(
359
+ title: "Password",
360
+ placeholder: "Enter your password",
361
+ text: $password,
362
+ isSecure: true,
363
+ textContentType: .password
364
+ )
365
+
366
+ KleponActionButton(
367
+ title: "Log in",
368
+ systemImage: "arrow.right.circle.fill",
369
+ isLoading: isBusy,
370
+ isDisabled: !canSubmit,
371
+ action: onSubmit
372
+ )
373
+
374
+ VStack(spacing: 10) {
375
+ KleponActionButton(
376
+ title: "Resend confirmation email",
377
+ systemImage: "envelope.badge",
378
+ tone: .secondary,
379
+ isDisabled: isBusy || !canRequestEmailAction,
380
+ action: onResendConfirmation
381
+ )
382
+
383
+ KleponActionButton(
384
+ title: "Forgot password",
385
+ systemImage: "key",
386
+ tone: .secondary,
387
+ isDisabled: isBusy || !canRequestEmailAction,
388
+ action: onForgotPassword
389
+ )
390
+ }
391
+ }
392
+ }
393
+ }
394
+}
395
+
396
+private struct AccountSignupCard: View {
397
+ @Binding var email: String
398
+ @Binding var password: String
399
+ @Binding var passwordConfirmation: String
400
+
401
+ let isBusy: Bool
402
+ let canSubmit: Bool
403
+ let onSubmit: () -> Void
404
+
405
+ private var passwordsMatch: Bool {
406
+ !password.isEmpty && password == passwordConfirmation
407
+ }
408
+
409
+ var body: some View {
410
+ KleponCard {
411
+ VStack(alignment: .leading, spacing: 14) {
412
+ Text("Sign up")
413
+ .font(KleponTypography.cardTitle)
414
+ .foregroundStyle(KleponColor.textPrimary)
415
+
416
+ Text(
417
+ "Create your account, then confirm your email before your first sign in."
418
+ )
419
+ .font(KleponTypography.bodySecondary)
420
+ .foregroundStyle(KleponColor.textSecondary)
421
+
422
+ AccountInputField(
423
+ title: "Email",
424
+ placeholder: "new-user@example.com",
425
+ text: $email,
426
+ textContentType: .username,
427
+ keyboard: .emailAddress
428
+ )
429
+
430
+ AccountInputField(
431
+ title: "Password",
432
+ placeholder: "Choose a password",
433
+ text: $password,
434
+ isSecure: true,
435
+ textContentType: .newPassword
436
+ )
437
+
438
+ AccountInputField(
439
+ title: "Confirm password",
440
+ placeholder: "Type it again",
441
+ text: $passwordConfirmation,
442
+ isSecure: true,
443
+ textContentType: .newPassword
444
+ )
445
+
446
+ if !passwordConfirmation.isEmpty {
447
+ Label(
448
+ passwordsMatch ? "Passwords match" : "Passwords must match",
449
+ systemImage: passwordsMatch
450
+ ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
451
+ )
452
+ .font(KleponTypography.caption)
453
+ .foregroundStyle(passwordsMatch ? KleponColor.accent : KleponColor.highlight)
454
+ }
455
+
456
+ KleponActionButton(
457
+ title: "Create account",
458
+ systemImage: "person.badge.plus",
459
+ isLoading: isBusy,
460
+ isDisabled: !canSubmit,
461
+ action: onSubmit
462
+ )
463
+ }
464
+ }
465
+ }
466
+}
467
+
468
+private struct AccountProfileCard: View {
469
+ let user: AccountUser?
470
+ let isBusy: Bool
471
+ let onRefresh: () -> Void
472
+ let onSaveProfile: (String, String) async -> Bool
473
+ let onUpdatePassword: (String, String, String) async -> Bool
474
+ let onLogout: () -> Void
475
+ let onRemoveAccount: () -> Void
476
+
477
+ @State private var isEditingProfile = false
478
+ @State private var name = ""
479
+ @State private var username = ""
480
+ @State private var currentPassword = ""
481
+ @State private var newPassword = ""
482
+ @State private var newPasswordConfirmation = ""
483
+
484
+ var body: some View {
485
+ KleponCard {
486
+ VStack(alignment: .leading, spacing: 14) {
487
+ HStack(alignment: .center) {
488
+ Text("Profile")
489
+ .font(KleponTypography.cardTitle)
490
+ .foregroundStyle(KleponColor.textPrimary)
491
+
492
+ Spacer()
493
+
494
+ if user != nil && !isEditingProfile {
495
+ Button("Edit") {
496
+ resetDraftValues()
497
+ isEditingProfile = true
498
+ }
499
+ .font(KleponTypography.caption.weight(.semibold))
500
+ .foregroundStyle(KleponColor.accent)
501
+ .disabled(isBusy)
502
+ }
503
+ }
504
+
505
+ if let user {
506
+ KleponMetadataRow(
507
+ title: "Name",
508
+ value: user.name?.isEmpty == false ? (user.name ?? "Not set") : "Not set"
509
+ )
510
+ KleponMetadataRow(
511
+ title: "Username",
512
+ value: user.usernameDisplay ?? "Not set"
513
+ )
514
+ KleponMetadataRow(title: "Email", value: user.email)
515
+ KleponMetadataRow(title: "User ID", value: "\(user.id)")
516
+ KleponMetadataRow(title: "Created", value: user.createdAt)
517
+ KleponMetadataRow(title: "Updated", value: user.updatedAt)
518
+ } else {
519
+ Text("Klepon is still loading the profile details for this session.")
520
+ .font(KleponTypography.bodySecondary)
521
+ .foregroundStyle(KleponColor.textSecondary)
522
+ }
523
+
524
+ if isEditingProfile {
525
+ AccountInputField(
526
+ title: "Name",
527
+ placeholder: "Your name",
528
+ text: $name
529
+ )
530
+
531
+ AccountInputField(
532
+ title: "Username",
533
+ placeholder: "yourname",
534
+ text: $username
535
+ )
536
+
537
+ Text(
538
+ "Use lowercase letters, numbers, dots, dashes, and underscores for your username."
539
+ )
540
+ .font(KleponTypography.caption)
541
+ .foregroundStyle(KleponColor.textSecondary)
542
+
543
+ HStack(spacing: 10) {
544
+ KleponActionButton(
545
+ title: "Save profile",
546
+ systemImage: "checkmark.circle.fill",
547
+ isLoading: isBusy,
548
+ action: {
549
+ Task {
550
+ let didSave = await onSaveProfile(name, username)
551
+ if didSave {
552
+ isEditingProfile = false
553
+ }
554
+ }
555
+ }
556
+ )
557
+
558
+ KleponActionButton(
559
+ title: "Cancel",
560
+ systemImage: "xmark.circle",
561
+ tone: .secondary,
562
+ isDisabled: isBusy,
563
+ action: {
564
+ resetDraftValues()
565
+ isEditingProfile = false
566
+ }
567
+ )
568
+ }
569
+ }
570
+
571
+ if user != nil {
572
+ VStack(alignment: .leading, spacing: 12) {
573
+ Text("Password")
574
+ .font(KleponTypography.bodySecondary.weight(.semibold))
575
+ .foregroundStyle(KleponColor.textPrimary)
576
+
577
+ if let passwordRulesSummary = user?.passwordRulesSummary,
578
+ !passwordRulesSummary.isEmpty
579
+ {
580
+ Text("Use \(passwordRulesSummary).")
581
+ .font(KleponTypography.caption)
582
+ .foregroundStyle(KleponColor.textSecondary)
583
+ }
584
+
585
+ AccountInputField(
586
+ title: "Current password",
587
+ placeholder: "Your current password",
588
+ text: $currentPassword,
589
+ isSecure: true,
590
+ textContentType: .password
591
+ )
592
+
593
+ AccountInputField(
594
+ title: "New password",
595
+ placeholder: "Choose a new password",
596
+ text: $newPassword,
597
+ isSecure: true,
598
+ textContentType: .newPassword
599
+ )
600
+
601
+ AccountInputField(
602
+ title: "Confirm new password",
603
+ placeholder: "Type it again",
604
+ text: $newPasswordConfirmation,
605
+ isSecure: true,
606
+ textContentType: .newPassword
607
+ )
608
+
609
+ if !newPasswordConfirmation.isEmpty
610
+ && newPassword != newPasswordConfirmation
611
+ {
612
+ Label(
613
+ "New password and confirmation must match",
614
+ systemImage: "exclamationmark.triangle.fill"
615
+ )
616
+ .font(KleponTypography.caption)
617
+ .foregroundStyle(KleponColor.highlight)
618
+ }
619
+
620
+ KleponActionButton(
621
+ title: "Update password",
622
+ systemImage: "key.fill",
623
+ tone: .secondary,
624
+ isLoading: isBusy,
625
+ isDisabled: currentPassword.isEmpty || newPassword.isEmpty
626
+ || newPasswordConfirmation.isEmpty
627
+ || newPassword != newPasswordConfirmation,
628
+ action: {
629
+ Task {
630
+ let didUpdate = await onUpdatePassword(
631
+ currentPassword,
632
+ newPassword,
633
+ newPasswordConfirmation
634
+ )
635
+ if didUpdate {
636
+ currentPassword = ""
637
+ newPassword = ""
638
+ newPasswordConfirmation = ""
639
+ }
640
+ }
641
+ }
642
+ )
643
+ }
644
+ }
645
+
646
+ HStack(spacing: 10) {
647
+ KleponActionButton(
648
+ title: "Refresh profile",
649
+ systemImage: "arrow.clockwise",
650
+ tone: .secondary,
651
+ isLoading: isBusy,
652
+ isDisabled: user == nil,
653
+ action: onRefresh
654
+ )
655
+
656
+ KleponActionButton(
657
+ title: "Log out",
658
+ systemImage: "rectangle.portrait.and.arrow.right",
659
+ tone: .secondary,
660
+ isDisabled: isBusy,
661
+ action: onLogout
662
+ )
663
+ }
664
+
665
+ KleponActionButton(
666
+ title: "Delete account",
667
+ systemImage: "trash",
668
+ tone: .secondary,
669
+ isDisabled: isBusy,
670
+ action: onRemoveAccount
671
+ )
672
+ }
673
+ .onAppear {
674
+ resetDraftValues()
675
+ }
676
+ .onChange(of: user?.updatedAt ?? "") { _, _ in
677
+ resetDraftValues()
678
+ }
679
+ }
680
+ }
681
+
682
+ private func resetDraftValues() {
683
+ name = user?.name ?? ""
684
+ username = user?.username ?? ""
685
+ }
686
+}
687
+
688
+private struct AccountInputField: View {
689
+ let title: String
690
+ let placeholder: String
691
+ @Binding var text: String
692
+ var isSecure = false
693
+ var textContentType: AccountTextContentType? = nil
694
+ var keyboard: AccountKeyboardType? = nil
695
+
696
+ var body: some View {
697
+ VStack(alignment: .leading, spacing: 8) {
698
+ Text(title)
699
+ .font(KleponTypography.caption)
700
+ .foregroundStyle(KleponColor.accentWarm)
701
+
702
+ Group {
703
+ if isSecure {
704
+ SecureField(placeholder, text: $text)
705
+ } else {
706
+ TextField(placeholder, text: $text)
707
+ }
708
+ }
709
+ .font(KleponTypography.body)
710
+ .foregroundColor(KleponColor.textPrimary)
711
+ .applyPlainTextFieldStyleForPlatform()
712
+ .padding(.horizontal, 14)
713
+ .padding(.vertical, 12)
714
+ .background(
715
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
716
+ .fill(KleponColor.surfaceSecondary)
717
+ )
718
+ .overlay(
719
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
720
+ .stroke(KleponColor.divider, lineWidth: 1)
721
+ )
722
+ .applyAccountInputTraits(textContentType: textContentType, keyboard: keyboard)
723
+ .disableAutocorrectionForPlatform()
724
+ .disableAutocapitalizationForPlatform()
725
+ }
726
+ }
727
+}
728
+
729
+extension View {
730
+ @ViewBuilder
731
+ fileprivate func modifyIfLet<Value, Modified: View>(
732
+ _ value: Value?,
733
+ @ViewBuilder transform: (Self, Value) -> Modified
734
+ ) -> some View {
735
+ if let value {
736
+ transform(self, value)
737
+ } else {
738
+ self
739
+ }
740
+ }
741
+
742
+ @ViewBuilder
743
+ fileprivate func applyAccountInputTraits(
744
+ textContentType: AccountTextContentType?,
745
+ keyboard: AccountKeyboardType?
746
+ ) -> some View {
747
+ #if os(iOS) || os(tvOS) || os(visionOS)
748
+ self
749
+ .modifyIfLet(textContentType) { view, textContentType in
750
+ switch textContentType {
751
+ case .username:
752
+ view.textContentType(.username)
753
+ case .password:
754
+ view.textContentType(.password)
755
+ case .newPassword:
756
+ view.textContentType(.newPassword)
757
+ }
758
+ }
759
+ .modifyIfLet(keyboard) { view, keyboard in
760
+ view.keyboardType(keyboard)
761
+ }
762
+ #else
763
+ self
764
+ #endif
765
+ }
766
+
767
+ @ViewBuilder
768
+ fileprivate func disableAutocorrectionForPlatform() -> some View {
769
+ #if os(iOS) || os(tvOS) || os(visionOS)
770
+ self.autocorrectionDisabled()
771
+ #else
772
+ self
773
+ #endif
774
+ }
775
+
776
+ @ViewBuilder
777
+ fileprivate func disableAutocapitalizationForPlatform() -> some View {
778
+ #if os(iOS) || os(tvOS) || os(visionOS)
779
+ self.textInputAutocapitalization(.never)
780
+ #else
781
+ self
782
+ #endif
783
+ }
784
+
785
+ @ViewBuilder
786
+ fileprivate func applyPlainTextFieldStyleForPlatform() -> some View {
787
+ #if os(macOS)
788
+ self.textFieldStyle(.plain)
789
+ #else
790
+ self
791
+ #endif
792
+ }
793
+}
Klepon/Shared/Account/DebugMenuView.swift
new
+202
@@ -0,0 +1,202 @@
1
+#if DEBUG
2
+ import SwiftUI
3
+
4
+ struct DebugView: View {
5
+ var body: some View {
6
+ ScrollView {
7
+ VStack(alignment: .leading, spacing: 18) {
8
+ #if os(tvOS)
9
+ TVPageHeader("Debug")
10
+ #endif
11
+
12
+ SectionHeader("Debug")
13
+
14
+ DebugPanelCard()
15
+ }
16
+ .padding(.horizontal, 20)
17
+ .padding(.vertical, 16)
18
+ .kleponReadableContentWidth()
19
+ }
20
+ .background(KleponColor.background.ignoresSafeArea())
21
+ .navigationTitle("Debug")
22
+ #if os(tvOS)
23
+ .toolbar(.hidden, for: .navigationBar)
24
+ #else
25
+ .kleponInlineNavigationTitle()
26
+ #endif
27
+ }
28
+ }
29
+
30
+ struct DebugSettingsCard: View {
31
+ @EnvironmentObject private var accountStore: AccountStore
32
+
33
+ var body: some View {
34
+ NavigationLink {
35
+ DebugView()
36
+ } label: {
37
+ KleponCard {
38
+ VStack(alignment: .leading, spacing: 12) {
39
+ HStack(alignment: .top, spacing: 12) {
40
+ Image(systemName: "ladybug.fill")
41
+ .font(.system(size: 24))
42
+ .foregroundStyle(.orange)
43
+
44
+ VStack(alignment: .leading, spacing: 6) {
45
+ Text("Debug")
46
+ .font(KleponTypography.cardTitle)
47
+ .foregroundStyle(KleponColor.textPrimary)
48
+
49
+ }
50
+
51
+ Spacer(minLength: 12)
52
+
53
+ Image(systemName: "chevron.right")
54
+ .font(.footnote.weight(.semibold))
55
+ .foregroundStyle(KleponColor.textSecondary)
56
+ }
57
+
58
+ HStack(spacing: 10) {
59
+ KleponChip(
60
+ title: accountStore.proxyEnvironment.displayName,
61
+ icon: "network"
62
+ )
63
+
64
+ KleponChip(
65
+ title: accountStore.isSignedIn ? "Session active" : "Signed out",
66
+ icon: accountStore.isSignedIn
67
+ ? "checkmark.circle" : "xmark.circle"
68
+ )
69
+ }
70
+ }
71
+ }
72
+ }
73
+ .kleponInteractiveButtonStyle()
74
+ }
75
+ }
76
+
77
+ private struct DebugPanelCard: View {
78
+ @EnvironmentObject private var accountStore: AccountStore
79
+
80
+ var body: some View {
81
+ KleponCard {
82
+ VStack(alignment: .leading, spacing: 14) {
83
+ debugHeader
84
+ environmentPicker
85
+ activeEndpointRow
86
+ sessionRow
87
+ Divider()
88
+ .background(KleponColor.textSecondary.opacity(0.2))
89
+ clearSessionButton
90
+ }
91
+ }
92
+ }
93
+
94
+ private var debugHeader: some View {
95
+ HStack(spacing: 8) {
96
+ Image(systemName: "ladybug.fill")
97
+ .foregroundStyle(.orange)
98
+ .font(.system(size: 14, weight: .semibold))
99
+ Text("Debug")
100
+ .font(KleponTypography.cardTitle)
101
+ .foregroundStyle(KleponColor.textPrimary)
102
+ Spacer()
103
+ environmentBadge
104
+ }
105
+ }
106
+
107
+ private var environmentBadge: some View {
108
+ Text(accountStore.proxyEnvironment.displayName.uppercased())
109
+ .font(.system(size: 10, weight: .bold, design: .monospaced))
110
+ .foregroundStyle(.white)
111
+ .padding(.horizontal, 8)
112
+ .padding(.vertical, 3)
113
+ .background(environmentBadgeColor.cornerRadius(4))
114
+ }
115
+
116
+ private var environmentBadgeColor: Color {
117
+ accountStore.proxyEnvironment == .development ? .orange : .green
118
+ }
119
+
120
+ private var environmentPicker: some View {
121
+ VStack(alignment: .leading, spacing: 8) {
122
+ Text("Environment")
123
+ .font(KleponTypography.bodySecondary)
124
+ .foregroundStyle(KleponColor.textSecondary)
125
+
126
+ Picker(
127
+ "Environment",
128
+ selection: Binding(
129
+ get: { accountStore.proxyEnvironment },
130
+ set: { accountStore.switchEnvironment($0) }
131
+ )
132
+ ) {
133
+ ForEach(AccountProxyEnvironment.allCases) { environment in
134
+ Text(environment.displayName).tag(environment)
135
+ }
136
+ }
137
+ .pickerStyle(.segmented)
138
+ .applySegmentedPickerStyleForPlatform()
139
+ }
140
+ }
141
+
142
+ private var activeEndpointRow: some View {
143
+ VStack(alignment: .leading, spacing: 4) {
144
+ Text("Active endpoint")
145
+ .font(KleponTypography.bodySecondary)
146
+ .foregroundStyle(KleponColor.textSecondary)
147
+ Text(accountStore.proxyEnvironment.baseURL.absoluteString)
148
+ .font(.system(size: 12, weight: .regular, design: .monospaced))
149
+ .foregroundStyle(environmentBadgeColor)
150
+ .lineLimit(1)
151
+ .truncationMode(.middle)
152
+ }
153
+ }
154
+
155
+ private var sessionRow: some View {
156
+ VStack(alignment: .leading, spacing: 4) {
157
+ Text("Session")
158
+ .font(KleponTypography.bodySecondary)
159
+ .foregroundStyle(KleponColor.textSecondary)
160
+ if accountStore.isSignedIn {
161
+ Text(accountStore.statusTitle)
162
+ .font(.system(size: 12, weight: .regular, design: .monospaced))
163
+ .foregroundStyle(.green)
164
+ } else {
165
+ Text("No active session")
166
+ .font(.system(size: 12, weight: .regular, design: .monospaced))
167
+ .foregroundStyle(KleponColor.textSecondary)
168
+ }
169
+ }
170
+ }
171
+
172
+ private var clearSessionButton: some View {
173
+ KleponActionButton(
174
+ title: "Clear session & sign out",
175
+ systemImage: "xmark.circle",
176
+ tone: .secondary
177
+ ) {
178
+ Task { await accountStore.logout() }
179
+ }
180
+ .disabled(!accountStore.isSignedIn)
181
+ .opacity(accountStore.isSignedIn ? 1 : 0.4)
182
+ }
183
+ }
184
+
185
+ extension View {
186
+ @ViewBuilder
187
+ fileprivate func applySegmentedPickerStyleForPlatform() -> some View {
188
+ #if os(macOS)
189
+ self.colorScheme(.light)
190
+ #else
191
+ self
192
+ #endif
193
+ }
194
+ }
195
+
196
+ #Preview {
197
+ NavigationStack {
198
+ DebugView()
199
+ .environmentObject(AccountStore())
200
+ }
201
+ }
202
+#endif
KleponMac/Info.plist
+4
@@ -21,6 +21,10 @@
21
<string>$(CURRENT_PROJECT_VERSION)</string>
22
<key>ITSAppUsesNonExemptEncryption</key>
23
<false />
24
+ <key>OndeAppId</key>
25
+ <string>$(ONDE_APP_ID)</string>
26
+ <key>OndeAppSecret</key>
27
+ <string>$(ONDE_APP_SECRET)</string>
28
<key>LSApplicationCategoryType</key>
29
<string>public.app-category.food-and-drink</string>
30
</dict>
KleponTV/App/AppDelegate.swift
new
+26
@@ -0,0 +1,26 @@
1
+import UIKit
2
+
3
+@main
4
+final class AppDelegate: UIResponder, UIApplicationDelegate {
5
+
6
+ func application(
7
+ _ application: UIApplication,
8
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
9
+ ) -> Bool {
10
+ OndeEnvironmentBootstrap.configureIfNeeded()
11
+ return true
12
+ }
13
+
14
+ func application(
15
+ _ application: UIApplication,
16
+ configurationForConnecting connectingSceneSession: UISceneSession,
17
+ options: UIScene.ConnectionOptions
18
+ ) -> UISceneConfiguration {
19
+ let configuration = UISceneConfiguration(
20
+ name: SceneDelegate.configurationName,
21
+ sessionRole: connectingSceneSession.role
22
+ )
23
+ configuration.delegateClass = SceneDelegate.self
24
+ return configuration
25
+ }
26
+}
KleponTV/App/KleponTabBarController.swift
new
+198
@@ -0,0 +1,198 @@
1
+import SwiftUI
2
+import UIKit
3
+
4
+@MainActor
5
+final class KleponTabBarController: UITabBarController {
6
+
7
+ private let appState: AppState
8
+ private var shouldGiveFocusToSelectedViewController = false
9
+
10
+ init(appState: AppState) {
11
+ self.appState = appState
12
+ super.init(nibName: nil, bundle: nil)
13
+ }
14
+
15
+ @available(*, unavailable)
16
+ required init?(coder: NSCoder) {
17
+ fatalError("init(coder:) has not been implemented")
18
+ }
19
+
20
+ override func viewDidLoad() {
21
+ super.viewDidLoad()
22
+ viewControllers = [
23
+ makeTab(
24
+ title: "Discover",
25
+ imageName: "sparkles",
26
+ viewController: makeDiscoverViewController()
27
+ ),
28
+ makeTab(
29
+ title: "Search",
30
+ imageName: "magnifyingglass",
31
+ viewController: makeSearchViewController()
32
+ ),
33
+ makeTab(
34
+ title: "Saved",
35
+ imageName: "heart",
36
+ viewController: makeSavedViewController()
37
+ ),
38
+ makeTab(
39
+ title: "Settings",
40
+ imageName: "gearshape.2",
41
+ viewController: makeSettingsViewController()
42
+ ),
43
+ ]
44
+ }
45
+
46
+ // MARK: - Focus engine
47
+
48
+ override var preferredFocusEnvironments: [UIFocusEnvironment] {
49
+ guard let selectedViewController, shouldGiveFocusToSelectedViewController else {
50
+ return super.preferredFocusEnvironments
51
+ }
52
+ shouldGiveFocusToSelectedViewController = false
53
+ return [selectedViewController]
54
+ }
55
+
56
+ override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool {
57
+ if let nextFocusedView = context.nextFocusedView,
58
+ !nextFocusedView.isDescendant(of: tabBar),
59
+ let previouslyFocusedView = context.previouslyFocusedView,
60
+ previouslyFocusedView.isDescendant(of: tabBar)
61
+ {
62
+ shouldGiveFocusToSelectedViewController = true
63
+ setNeedsFocusUpdate()
64
+ updateFocusIfNeeded()
65
+ return false
66
+ }
67
+ shouldGiveFocusToSelectedViewController = false
68
+ return true
69
+ }
70
+
71
+ // MARK: - Top Shelf routing
72
+
73
+ func handle(url: URL) {
74
+ guard let route = TopShelfRoute(url: url, repository: appState.contentRepository) else {
75
+ return
76
+ }
77
+
78
+ selectedIndex = 0
79
+ dismissPresentedContentIfNeeded { [weak self] in
80
+ self?.present(route: route)
81
+ }
82
+ }
83
+
84
+ private func dismissPresentedContentIfNeeded(completion: @escaping () -> Void) {
85
+ if let activePresentedViewController = selectedViewController?.presentedViewController
86
+ ?? presentedViewController
87
+ {
88
+ activePresentedViewController.dismiss(animated: false, completion: completion)
89
+ } else {
90
+ completion()
91
+ }
92
+ }
93
+
94
+ private func present(route: TopShelfRoute) {
95
+ switch route {
96
+ case .entry(let entry):
97
+ let detailView = NavigationStack {
98
+ GuideDetailView(entry: entry)
99
+ }
100
+ .withKleponEnvironment(appState: appState)
101
+ let hostingController = UIHostingController(rootView: detailView)
102
+ hostingController.modalPresentationStyle = .fullScreen
103
+ presentationSourceViewController.present(hostingController, animated: true)
104
+ case .ask(let entry):
105
+ let askView = NavigationStack {
106
+ AskSheetView(entry: entry, initialQuestion: nil)
107
+ }
108
+ .withKleponEnvironment(appState: appState)
109
+ let hostingController = UIHostingController(rootView: askView)
110
+ hostingController.modalPresentationStyle = .fullScreen
111
+ presentationSourceViewController.present(hostingController, animated: true)
112
+ }
113
+ }
114
+
115
+ private var presentationSourceViewController: UIViewController {
116
+ selectedViewController ?? self
117
+ }
118
+
119
+ // MARK: - Tab factories
120
+
121
+ private func makeTab(
122
+ title: String,
123
+ imageName: String,
124
+ viewController: UIViewController
125
+ ) -> UIViewController {
126
+ viewController.tabBarItem = UITabBarItem(
127
+ title: title,
128
+ image: UIImage(systemName: imageName),
129
+ selectedImage: UIImage(systemName: "\(imageName).fill")
130
+ )
131
+ viewController.tabBarItem.accessibilityIdentifier = title.lowercased()
132
+ return viewController
133
+ }
134
+
135
+ private func makeDiscoverViewController() -> UIViewController {
136
+ // DiscoverView on tvOS uses a constant binding for showingSettings (settings is a tab)
137
+ let view = DiscoverView(showingSettings: .constant(false))
138
+ .withKleponEnvironment(appState: appState)
139
+ return UIHostingController(rootView: view)
140
+ }
141
+
142
+ private func makeSearchViewController() -> UIViewController {
143
+ let view = SearchView()
144
+ .withKleponEnvironment(appState: appState)
145
+ return UIHostingController(rootView: view)
146
+ }
147
+
148
+ private func makeSavedViewController() -> UIViewController {
149
+ let view = SavedView()
150
+ .withKleponEnvironment(appState: appState)
151
+ return UIHostingController(rootView: view)
152
+ }
153
+
154
+ private func makeSettingsViewController() -> UIViewController {
155
+ let view = SettingsView()
156
+ .withKleponEnvironment(appState: appState)
157
+ return UIHostingController(rootView: view)
158
+ }
159
+}
160
+
161
+private enum TopShelfRoute {
162
+ case entry(GuideEntry)
163
+ case ask(GuideEntry)
164
+
165
+ init?(url: URL, repository: ContentRepository) {
166
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
167
+ components.scheme?.lowercased() == "klepon",
168
+ let host = components.host?.lowercased(),
169
+ let entryID = components.queryItems?.first(where: { $0.name == "id" })?.value,
170
+ let entry = repository.entry(id: entryID)
171
+ else {
172
+ return nil
173
+ }
174
+
175
+ switch host {
176
+ case "entry":
177
+ self = .entry(entry)
178
+ case "ask":
179
+ self = .ask(entry)
180
+ default:
181
+ return nil
182
+ }
183
+ }
184
+}
185
+
186
+// MARK: - Environment injection helper
187
+
188
+extension View {
189
+ fileprivate func withKleponEnvironment(appState: AppState) -> some View {
190
+ self
191
+ .environmentObject(appState)
192
+ .environmentObject(appState.favoritesStore)
193
+ .environmentObject(appState.recentSearchStore)
194
+ .environmentObject(appState.recentlyViewedStore)
195
+ .environmentObject(appState.guideEngine)
196
+ .environmentObject(appState.accountStore)
197
+ }
198
+}
KleponTV/App/SceneDelegate.swift
new
+65
@@ -0,0 +1,65 @@
1
+import SwiftUI
2
+import UIKit
3
+
4
+@MainActor
5
+final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6
+ static let configurationName = "Default Configuration"
7
+
8
+ var window: UIWindow?
9
+
10
+ private let appState = AppState()
11
+
12
+ func scene(
13
+ _ scene: UIScene,
14
+ willConnectTo session: UISceneSession,
15
+ options connectionOptions: UIScene.ConnectionOptions
16
+ ) {
17
+ guard let windowScene = scene as? UIWindowScene else { return }
18
+
19
+ let launchURL = connectionOptions.urlContexts.first?.url
20
+ let tabBarController = KleponTabBarController(appState: appState)
21
+ let window = UIWindow(windowScene: windowScene)
22
+ window.rootViewController = tabBarController
23
+ window.makeKeyAndVisible()
24
+ self.window = window
25
+
26
+ if let launchURL {
27
+ tabBarController.handle(url: launchURL)
28
+ }
29
+
30
+ Task {
31
+ appState.guideEngine.refreshStorageUsage()
32
+ await appState.accountStore.restoreSessionIfNeeded()
33
+ if launchURL == nil, !appState.hasCompletedOnboarding {
34
+ await showOnboarding(from: tabBarController)
35
+ }
36
+ }
37
+ }
38
+
39
+ func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
40
+ guard let url = URLContexts.first?.url,
41
+ let tabBarController = window?.rootViewController as? KleponTabBarController
42
+ else {
43
+ return
44
+ }
45
+
46
+ tabBarController.handle(url: url)
47
+ }
48
+
49
+ private func showOnboarding(from presenter: UIViewController) async {
50
+ let onboardingView = OnboardingView(
51
+ onBrowseFirst: { [weak self] in self?.appState.completeOnboarding() },
52
+ onComplete: { [weak self] in self?.appState.completeOnboarding() }
53
+ )
54
+ .environmentObject(appState)
55
+ .environmentObject(appState.favoritesStore)
56
+ .environmentObject(appState.recentSearchStore)
57
+ .environmentObject(appState.recentlyViewedStore)
58
+ .environmentObject(appState.guideEngine)
59
+ .environmentObject(appState.accountStore)
60
+
61
+ let hostingController = UIHostingController(rootView: onboardingView)
62
+ hostingController.modalPresentationStyle = .fullScreen
63
+ presenter.present(hostingController, animated: true)
64
+ }
65
+}
KleponTV/Info.plist
+32
@@ -16,10 +16,42 @@
16
<string>APPL</string>
17
<key>CFBundleShortVersionString</key>
18
<string>$(MARKETING_VERSION)</string>
19
+ <key>CFBundleURLTypes</key>
20
+ <array>
21
+ <dict>
22
+ <key>CFBundleURLName</key>
23
+ <string>ai.splitfire.klepon.tvos</string>
24
+ <key>CFBundleURLSchemes</key>
25
+ <array>
26
+ <string>klepon</string>
27
+ </array>
28
+ </dict>
29
+ </array>
30
<key>CFBundleVersion</key>
31
<string>$(CURRENT_PROJECT_VERSION)</string>
32
<key>ITSAppUsesNonExemptEncryption</key>
33
<false/>
34
+ <key>OndeAppId</key>
35
+ <string>$(ONDE_APP_ID)</string>
36
+ <key>OndeAppSecret</key>
37
+ <string>$(ONDE_APP_SECRET)</string>
38
+ <key>UIApplicationSceneManifest</key>
39
+ <dict>
40
+ <key>UIApplicationSupportsMultipleScenes</key>
41
+ <false/>
42
+ <key>UISceneConfigurations</key>
43
+ <dict>
44
+ <key>UIWindowSceneSessionRoleApplication</key>
45
+ <array>
46
+ <dict>
47
+ <key>UISceneConfigurationName</key>
48
+ <string>Default Configuration</string>
49
+ <key>UISceneDelegateClassName</key>
50
+ <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
51
+ </dict>
52
+ </array>
53
+ </dict>
54
+ </dict>
55
<key>UILaunchScreen</key>
56
<dict/>
57
</dict>
KleponTV/KleponTV.entitlements
+2
@@ -2,6 +2,8 @@
2
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
<plist version="1.0">
4
<dict>
5
+ <key>com.apple.developer.kernel.increased-memory-limit</key>
6
+ <true/>
7
<key>com.apple.security.application-groups</key>
8
<array>
9
<string>group.com.ondeinference.apps</string>
KleponTVTopShelfExtension/ContentProvider.swift
new
+96
@@ -0,0 +1,96 @@
1
+import Foundation
2
+@preconcurrency import TVServices
3
+
4
+final class ContentProvider: TVTopShelfContentProvider {
5
+ override func loadTopShelfContent() async -> (any TVTopShelfContent)? {
6
+ var carouselItems: [TVTopShelfCarouselItem] = []
7
+
8
+ for item in TopShelfCatalog.items {
9
+ let carouselItem = TVTopShelfCarouselItem(identifier: item.id)
10
+ carouselItem.title = item.title
11
+ carouselItem.contextTitle = item.contextTitle
12
+ carouselItem.summary = item.summary
13
+ carouselItem.genre = item.kindTitle
14
+ carouselItem.namedAttributes = [
15
+ TVTopShelfNamedAttribute(name: "Section", values: [item.sectionTitle]),
16
+ TVTopShelfNamedAttribute(name: "Type", values: [item.kindTitle]),
17
+ ]
18
+ carouselItem.displayAction = TVTopShelfAction(url: item.displayURL)
19
+ carouselItem.playAction = TVTopShelfAction(url: item.playURL)
20
+
21
+ if let imageURL = Bundle.main.url(forResource: item.artworkName, withExtension: "png") {
22
+ carouselItem.setImageURL(imageURL, for: .screenScale1x)
23
+ carouselItem.setImageURL(imageURL, for: .screenScale2x)
24
+ }
25
+
26
+ carouselItems.append(carouselItem)
27
+ }
28
+
29
+ return TVTopShelfCarouselContent(style: .details, items: carouselItems)
30
+ }
31
+}
32
+
33
+private enum TopShelfCatalog {
34
+ static let items: [Item] = [
35
+ Item(
36
+ id: "klepon",
37
+ title: "Klepon",
38
+ contextTitle: "Featured sweet",
39
+ summary:
40
+ "A chewy rice cake with molten palm sugar and soft coconut that feels instantly comforting.",
41
+ kindTitle: "Dish",
42
+ sectionTitle: "Discover",
43
+ artworkName: "topshelf-klepon",
44
+ displayURL: URL(string: "klepon://entry?id=klepon")!,
45
+ playURL: URL(string: "klepon://ask?id=klepon")!
46
+ ),
47
+ Item(
48
+ id: "rendang",
49
+ title: "Rendang",
50
+ contextTitle: "Deep and celebratory",
51
+ summary:
52
+ "Slow-cooked beef, coconut, and spice reduced into one of Indonesia's richest classics.",
53
+ kindTitle: "Dish",
54
+ sectionTitle: "Discover",
55
+ artworkName: "topshelf-rendang",
56
+ displayURL: URL(string: "klepon://entry?id=rendang")!,
57
+ playURL: URL(string: "klepon://ask?id=rendang")!
58
+ ),
59
+ Item(
60
+ id: "sambal",
61
+ title: "Sambal",
62
+ contextTitle: "Pantry essential",
63
+ summary:
64
+ "A whole family of chile condiments that can be bright, smoky, sweet, sharp, or deeply savory.",
65
+ kindTitle: "Ingredient",
66
+ sectionTitle: "Discover",
67
+ artworkName: "topshelf-sambal",
68
+ displayURL: URL(string: "klepon://entry?id=sambal")!,
69
+ playURL: URL(string: "klepon://ask?id=sambal")!
70
+ ),
71
+ Item(
72
+ id: "street-food",
73
+ title: "Street food culture",
74
+ contextTitle: "Daily food rhythm",
75
+ summary:
76
+ "Portable meals, market snacks, and fast comfort that shape how many people first meet Indonesian food.",
77
+ kindTitle: "Tradition",
78
+ sectionTitle: "Discover",
79
+ artworkName: "topshelf-street-food",
80
+ displayURL: URL(string: "klepon://entry?id=street-food")!,
81
+ playURL: URL(string: "klepon://ask?id=street-food")!
82
+ ),
83
+ ]
84
+
85
+ struct Item {
86
+ let id: String
87
+ let title: String
88
+ let contextTitle: String
89
+ let summary: String
90
+ let kindTitle: String
91
+ let sectionTitle: String
92
+ let artworkName: String
93
+ let displayURL: URL
94
+ let playURL: URL
95
+ }
96
+}
KleponTVTopShelfExtension/Info.plist
new
+35
@@ -0,0 +1,35 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>Klepon TV Top Shelf</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>$(EXECUTABLE_NAME)</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+ <key>CFBundleInfoDictionaryVersion</key>
14
+ <string>6.0</string>
15
+ <key>CFBundleName</key>
16
+ <string>$(PRODUCT_NAME)</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>XPC!</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>$(MARKETING_VERSION)</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>$(CURRENT_PROJECT_VERSION)</string>
23
+ <key>NSExtension</key>
24
+ <dict>
25
+ <key>NSExtensionPointIdentifier</key>
26
+ <string>com.apple.tv-top-shelf</string>
27
+ <key>NSExtensionPrincipalClass</key>
28
+ <string>$(PRODUCT_MODULE_NAME).ContentProvider</string>
29
+ </dict>
30
+ <key>UIRequiredDeviceCapabilities</key>
31
+ <array>
32
+ <string>arm64</string>
33
+ </array>
34
+</dict>
35
+</plist>
KleponTVTopShelfExtension/topshelf-klepon.png
Binary files /dev/null and b/KleponTVTopShelfExtension/topshelf-klepon.png differ
KleponTVTopShelfExtension/topshelf-rendang.png
Binary files /dev/null and b/KleponTVTopShelfExtension/topshelf-rendang.png differ
KleponTVTopShelfExtension/topshelf-sambal.png
Binary files /dev/null and b/KleponTVTopShelfExtension/topshelf-sambal.png differ
KleponTVTopShelfExtension/topshelf-street-food.png
Binary files /dev/null and b/KleponTVTopShelfExtension/topshelf-street-food.png differ
KleponVision/Info.plist
+9
-4
@@ -1,5 +1,6 @@
1
-<?xml version="1.0" encoding="UTF-8"?>
2
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1
+<?xml version="1.0" encoding="UTF-8" ?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
<plist version="1.0">
5
<dict>
6
<key>CFBundleDevelopmentRegion</key>
@@ -19,8 +20,12 @@
20
<key>CFBundleVersion</key>
21
<string>$(CURRENT_PROJECT_VERSION)</string>
22
<key>ITSAppUsesNonExemptEncryption</key>
22
- <false/>
23
+ <false />
24
+ <key>OndeAppId</key>
25
+ <string>$(ONDE_APP_ID)</string>
26
+ <key>OndeAppSecret</key>
27
+ <string>$(ONDE_APP_SECRET)</string>
28
<key>UILaunchScreen</key>
24
- <dict/>
29
+ <dict />
30
</dict>
31
</plist>
KleponWatch/Info.plist
+1
-1
@@ -24,6 +24,6 @@
24
<key>WKApplication</key>
25
<true />
26
<key>WKCompanionAppBundleIdentifier</key>
27
- <string>ai.splitfire.klepon</string>
27
+ <string>$(KLEPON_COMPANION_BUNDLE_ID)</string>
28
</dict>
29
</plist>
Scripts/generate_topshelf_assets.sh
new
+6
@@ -0,0 +1,6 @@
1
+#!/bin/sh
2
+set -eu
3
+
4
+REPO_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
5
+cd "$REPO_ROOT"
6
+swift "$REPO_ROOT/Scripts/generate_topshelf_assets.swift" "$@"
Scripts/generate_topshelf_assets.swift
new
+266
@@ -0,0 +1,266 @@
1
+#!/usr/bin/swift
2
+
3
+import AppKit
4
+import Foundation
5
+
6
+struct TopShelfArtwork {
7
+ let filename: String
8
+ let section: String
9
+ let eyebrow: String
10
+ let title: String
11
+ let summary: String
12
+ let symbolName: String
13
+ let backgroundLeft: NSColor
14
+ let backgroundCenter: NSColor
15
+ let backgroundRight: NSColor
16
+ let accentColor: NSColor
17
+}
18
+
19
+private let canvasSize = CGSize(width: 3840, height: 1440)
20
+private let titleSafeLeftInset: CGFloat = 880
21
+private let outputDirectory = URL(
22
+ fileURLWithPath: CommandLine.arguments.contains("--output-dir")
23
+ ? CommandLine.arguments[
24
+ CommandLine.arguments.firstIndex(of: "--output-dir")!.advanced(by: 1)]
25
+ : defaultOutputDirectoryPath()
26
+)
27
+
28
+private let assets: [TopShelfArtwork] = [
29
+ .init(
30
+ filename: "topshelf-klepon.png",
31
+ section: "DISCOVER",
32
+ eyebrow: "Featured sweet",
33
+ title: "Klepon",
34
+ summary:
35
+ "A chewy rice cake with molten palm sugar and soft coconut that feels instantly comforting.",
36
+ symbolName: "circle.hexagongrid.fill",
37
+ backgroundLeft: NSColor(calibratedRed: 0.95, green: 0.91, blue: 0.82, alpha: 1),
38
+ backgroundCenter: NSColor(calibratedRed: 0.63, green: 0.82, blue: 0.58, alpha: 1),
39
+ backgroundRight: NSColor(calibratedRed: 0.14, green: 0.34, blue: 0.24, alpha: 1),
40
+ accentColor: NSColor(calibratedRed: 0.89, green: 0.61, blue: 0.27, alpha: 1)
41
+ ),
42
+ .init(
43
+ filename: "topshelf-rendang.png",
44
+ section: "DISCOVER",
45
+ eyebrow: "Deep and celebratory",
46
+ title: "Rendang",
47
+ summary:
48
+ "Slow-cooked beef, coconut, and spice reduced into one of Indonesia's richest classics.",
49
+ symbolName: "flame.fill",
50
+ backgroundLeft: NSColor(calibratedRed: 0.51, green: 0.24, blue: 0.14, alpha: 1),
51
+ backgroundCenter: NSColor(calibratedRed: 0.71, green: 0.35, blue: 0.20, alpha: 1),
52
+ backgroundRight: NSColor(calibratedRed: 0.19, green: 0.10, blue: 0.08, alpha: 1),
53
+ accentColor: NSColor(calibratedRed: 0.95, green: 0.74, blue: 0.44, alpha: 1)
54
+ ),
55
+ .init(
56
+ filename: "topshelf-sambal.png",
57
+ section: "DISCOVER",
58
+ eyebrow: "Pantry essential",
59
+ title: "Sambal",
60
+ summary:
61
+ "Bright, smoky, sweet, or sharp, sambal changes the whole plate with one spoonful.",
62
+ symbolName: "leaf.fill",
63
+ backgroundLeft: NSColor(calibratedRed: 0.81, green: 0.26, blue: 0.20, alpha: 1),
64
+ backgroundCenter: NSColor(calibratedRed: 0.93, green: 0.46, blue: 0.27, alpha: 1),
65
+ backgroundRight: NSColor(calibratedRed: 0.24, green: 0.12, blue: 0.08, alpha: 1),
66
+ accentColor: NSColor(calibratedRed: 0.99, green: 0.82, blue: 0.53, alpha: 1)
67
+ ),
68
+ .init(
69
+ filename: "topshelf-street-food.png",
70
+ section: "DISCOVER",
71
+ eyebrow: "Daily food rhythm",
72
+ title: "Street food culture",
73
+ summary:
74
+ "Portable meals, market snacks, and fast comfort that shape how many people first meet Indonesian food.",
75
+ symbolName: "sparkles",
76
+ backgroundLeft: NSColor(calibratedRed: 0.22, green: 0.42, blue: 0.31, alpha: 1),
77
+ backgroundCenter: NSColor(calibratedRed: 0.37, green: 0.60, blue: 0.44, alpha: 1),
78
+ backgroundRight: NSColor(calibratedRed: 0.10, green: 0.17, blue: 0.13, alpha: 1),
79
+ accentColor: NSColor(calibratedRed: 0.97, green: 0.84, blue: 0.60, alpha: 1)
80
+ ),
81
+]
82
+
83
+do {
84
+ try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
85
+
86
+ for asset in assets {
87
+ let image = draw(asset: asset)
88
+ let destination = outputDirectory.appendingPathComponent(asset.filename)
89
+ guard
90
+ let data = image.tiffRepresentation,
91
+ let representation = NSBitmapImageRep(data: data),
92
+ let png = representation.representation(using: .png, properties: [:])
93
+ else {
94
+ throw NSError(
95
+ domain: "TopShelfGenerator",
96
+ code: 1,
97
+ userInfo: [NSLocalizedDescriptionKey: "Failed to encode \(asset.filename)"]
98
+ )
99
+ }
100
+
101
+ try png.write(to: destination, options: .atomic)
102
+ print("wrote \(destination.path)")
103
+ }
104
+} catch {
105
+ fputs("Top Shelf generation failed: \(error)\n", stderr)
106
+ exit(1)
107
+}
108
+
109
+private func defaultOutputDirectoryPath() -> String {
110
+ URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
111
+ .appendingPathComponent("KleponTVTopShelfExtension")
112
+ .path
113
+}
114
+
115
+private func draw(asset: TopShelfArtwork) -> NSImage {
116
+ let image = NSImage(size: canvasSize)
117
+ image.lockFocusFlipped(true)
118
+ defer { image.unlockFocus() }
119
+
120
+ guard let context = NSGraphicsContext.current else {
121
+ return image
122
+ }
123
+
124
+ context.shouldAntialias = true
125
+ context.imageInterpolation = .high
126
+
127
+ drawBackground(for: asset)
128
+ drawForeground(for: asset)
129
+
130
+ return image
131
+}
132
+
133
+private func drawBackground(for asset: TopShelfArtwork) {
134
+ let fullRect = CGRect(origin: .zero, size: canvasSize)
135
+ let gradient = NSGradient(colors: [
136
+ asset.backgroundLeft, asset.backgroundCenter, asset.backgroundRight,
137
+ ])!
138
+ gradient.draw(in: fullRect, angle: 0)
139
+
140
+ let topRightCircle = NSBezierPath(
141
+ ovalIn: CGRect(x: canvasSize.width - 1120, y: -80, width: 1160, height: 1160)
142
+ )
143
+ NSColor(calibratedWhite: 1, alpha: 0.10).setFill()
144
+ topRightCircle.fill()
145
+
146
+ let rightGlow = NSBezierPath(
147
+ ovalIn: CGRect(x: canvasSize.width - 1480, y: 260, width: 1240, height: 840)
148
+ )
149
+ asset.accentColor.withAlphaComponent(0.18).setFill()
150
+ rightGlow.fill()
151
+
152
+ let rightCard = NSBezierPath(
153
+ roundedRect: CGRect(x: 2560, y: 240, width: 980, height: 700), xRadius: 82, yRadius: 82)
154
+ NSColor(calibratedWhite: 1, alpha: 0.14).setFill()
155
+ rightCard.fill()
156
+
157
+ let innerCard = NSBezierPath(
158
+ roundedRect: CGRect(x: 2680, y: 350, width: 620, height: 470), xRadius: 58, yRadius: 58)
159
+ NSColor(calibratedWhite: 1, alpha: 0.08).setFill()
160
+ innerCard.fill()
161
+
162
+ let glowBar = NSBezierPath(
163
+ roundedRect: CGRect(x: 2280, y: 510, width: 270, height: 260), xRadius: 110, yRadius: 110)
164
+ NSColor(calibratedWhite: 1, alpha: 0.16).setFill()
165
+ glowBar.fill()
166
+}
167
+
168
+private func drawForeground(for asset: TopShelfArtwork) {
169
+ let leftInset = titleSafeLeftInset
170
+
171
+ drawText(
172
+ asset.section,
173
+ in: CGRect(x: leftInset, y: 190, width: 420, height: 60),
174
+ font: font(name: "Avenir Next Demi Bold", size: 42) ?? font(name: "Arial Bold", size: 42)!,
175
+ color: NSColor(calibratedWhite: 1, alpha: 0.82),
176
+ alignment: .left,
177
+ uppercase: true
178
+ )
179
+
180
+ drawText(
181
+ asset.eyebrow,
182
+ in: CGRect(x: leftInset, y: 350, width: 980, height: 86),
183
+ font: font(name: "Avenir Next Demi Bold", size: 60) ?? font(name: "Arial Bold", size: 60)!,
184
+ color: NSColor(calibratedWhite: 1, alpha: 0.94),
185
+ alignment: .left
186
+ )
187
+
188
+ drawText(
189
+ asset.title,
190
+ in: CGRect(x: leftInset, y: 480, width: 1560, height: 200),
191
+ font: font(name: "Avenir Next Bold", size: 124) ?? font(name: "Arial Bold", size: 124)!,
192
+ color: .white,
193
+ alignment: .left
194
+ )
195
+
196
+ drawText(
197
+ asset.summary,
198
+ in: CGRect(x: leftInset, y: 720, width: 1700, height: 180),
199
+ font: font(name: "Avenir Next Regular", size: 54) ?? font(name: "Arial", size: 54)!,
200
+ color: NSColor(calibratedWhite: 1, alpha: 0.88),
201
+ alignment: .left
202
+ )
203
+
204
+ let orbRect = CGRect(x: 2790, y: 365, width: 410, height: 410)
205
+ let orb = NSBezierPath(ovalIn: orbRect)
206
+ let orbGradient = NSGradient(colors: [
207
+ asset.accentColor.withAlphaComponent(0.92),
208
+ asset.accentColor.withAlphaComponent(0.64),
209
+ NSColor(calibratedWhite: 1, alpha: 0.18),
210
+ ])!
211
+ orbGradient.draw(in: orb, angle: 45)
212
+
213
+ let orbHighlight = NSBezierPath(ovalIn: CGRect(x: 2870, y: 440, width: 180, height: 120))
214
+ NSColor(calibratedWhite: 1, alpha: 0.18).setFill()
215
+ orbHighlight.fill()
216
+
217
+ if let symbol = symbolImage(named: asset.symbolName, pointSize: 150, tint: .white) {
218
+ symbol.draw(in: CGRect(x: 2915, y: 485, width: 160, height: 160))
219
+ }
220
+}
221
+
222
+private func drawText(
223
+ _ string: String,
224
+ in rect: CGRect,
225
+ font: NSFont,
226
+ color: NSColor,
227
+ alignment: NSTextAlignment,
228
+ uppercase: Bool = false
229
+) {
230
+ let paragraph = NSMutableParagraphStyle()
231
+ paragraph.alignment = alignment
232
+ paragraph.lineBreakMode = .byWordWrapping
233
+
234
+ let attributed = NSAttributedString(
235
+ string: uppercase ? string.uppercased() : string,
236
+ attributes: [
237
+ .font: font,
238
+ .foregroundColor: color,
239
+ .paragraphStyle: paragraph,
240
+ ]
241
+ )
242
+
243
+ attributed.draw(in: rect)
244
+}
245
+
246
+private func font(name: String, size: CGFloat) -> NSFont? {
247
+ NSFont(name: name, size: size)
248
+}
249
+
250
+private func symbolImage(named name: String, pointSize: CGFloat, tint: NSColor) -> NSImage? {
251
+ let configuration = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .regular)
252
+ guard
253
+ let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
254
+ .withSymbolConfiguration(configuration)
255
+ else {
256
+ return nil
257
+ }
258
+
259
+ let tinted = image.copy() as? NSImage
260
+ tinted?.lockFocus()
261
+ tint.set()
262
+ let imageRect = CGRect(origin: .zero, size: image.size)
263
+ imageRect.fill(using: .sourceAtop)
264
+ tinted?.unlockFocus()
265
+ return tinted
266
+}
Scripts/generate_tvos_brand_assets.sh
new
+6
@@ -0,0 +1,6 @@
1
+#!/bin/sh
2
+set -eu
3
+
4
+REPO_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
5
+cd "$REPO_ROOT"
6
+swift "$REPO_ROOT/Scripts/generate_tvos_brand_assets.swift" "$@"
Scripts/generate_tvos_brand_assets.swift
new
+699
@@ -0,0 +1,699 @@
1
+#!/usr/bin/swift
2
+
3
+import AppKit
4
+import Foundation
5
+
6
+private let bundleIdentifier = "ai.splitfire.klepon"
7
+private let catalogName = "AppIconTV.brandassets"
8
+private let outputDirectory = URL(
9
+ fileURLWithPath: CommandLine.arguments.contains("--output-dir")
10
+ ? CommandLine.arguments[
11
+ CommandLine.arguments.firstIndex(of: "--output-dir")!.advanced(by: 1)]
12
+ : defaultOutputDirectoryPath()
13
+)
14
+
15
+private let backgroundColor = NSColor(calibratedRed: 0.98, green: 0.96, blue: 0.92, alpha: 1)
16
+private let surfaceColor = NSColor(calibratedRed: 0.98, green: 0.95, blue: 0.89, alpha: 1)
17
+private let surfaceSecondaryColor = NSColor(calibratedRed: 0.93, green: 0.90, blue: 0.84, alpha: 1)
18
+private let accentColor = NSColor(calibratedRed: 0.17, green: 0.48, blue: 0.34, alpha: 1)
19
+private let accentLightColor = NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 1)
20
+private let accentDarkColor = NSColor(calibratedRed: 0.12, green: 0.34, blue: 0.23, alpha: 1)
21
+private let warmColor = NSColor(calibratedRed: 0.56, green: 0.35, blue: 0.17, alpha: 1)
22
+private let warmLightColor = NSColor(calibratedRed: 0.90, green: 0.60, blue: 0.25, alpha: 1)
23
+private let textPrimaryColor = NSColor(calibratedRed: 0.17, green: 0.16, blue: 0.13, alpha: 1)
24
+private let textSecondaryColor = NSColor(calibratedRed: 0.37, green: 0.35, blue: 0.31, alpha: 1)
25
+private let dividerColor = NSColor(calibratedWhite: 0, alpha: 0.08)
26
+private let coconutColor = NSColor(calibratedRed: 0.99, green: 0.98, blue: 0.94, alpha: 1)
27
+private let coconutShadowColor = NSColor(calibratedRed: 0.88, green: 0.85, blue: 0.76, alpha: 0.72)
28
+
29
+private let coconutFlakes: [(CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)] = [
30
+ (0.16, 0.34, 0.050, 0.026, -18),
31
+ (0.19, 0.31, 0.042, 0.024, 12),
32
+ (0.22, 0.28, 0.052, 0.028, -8),
33
+ (0.25, 0.25, 0.044, 0.025, 9),
34
+ (0.28, 0.22, 0.055, 0.028, -12),
35
+ (0.32, 0.19, 0.048, 0.026, 14),
36
+ (0.36, 0.16, 0.056, 0.030, -10),
37
+ (0.41, 0.13, 0.046, 0.026, 6),
38
+ (0.46, 0.11, 0.054, 0.029, -14),
39
+ (0.51, 0.10, 0.043, 0.024, 11),
40
+ (0.56, 0.11, 0.051, 0.028, -9),
41
+ (0.61, 0.13, 0.046, 0.025, 13),
42
+ (0.66, 0.17, 0.056, 0.030, -11),
43
+ (0.70, 0.20, 0.050, 0.027, 8),
44
+ (0.74, 0.24, 0.047, 0.026, -7),
45
+ (0.77, 0.28, 0.055, 0.029, 12),
46
+ (0.80, 0.32, 0.048, 0.026, -9),
47
+ (0.24, 0.37, 0.032, 0.020, -6),
48
+ (0.29, 0.34, 0.034, 0.021, 13),
49
+ (0.35, 0.30, 0.033, 0.020, -10),
50
+ (0.40, 0.26, 0.035, 0.022, 5),
51
+ (0.47, 0.23, 0.032, 0.020, -15),
52
+ (0.54, 0.22, 0.034, 0.021, 7),
53
+ (0.60, 0.24, 0.033, 0.020, -8),
54
+ (0.67, 0.28, 0.035, 0.022, 14),
55
+ (0.72, 0.33, 0.032, 0.020, -11),
56
+]
57
+
58
+private let smallIconCanvas = CGSize(width: 400, height: 240)
59
+private let appStoreCanvas = CGSize(width: 1280, height: 768)
60
+private let topShelfCanvas = CGSize(width: 1920, height: 720)
61
+private let topShelfWideCanvas = CGSize(width: 2320, height: 720)
62
+
63
+do {
64
+ try regenerateCatalog()
65
+} catch {
66
+ fputs("tvOS brand asset generation failed: \(error)\n", stderr)
67
+ exit(1)
68
+}
69
+
70
+private func regenerateCatalog() throws {
71
+ if FileManager.default.fileExists(atPath: outputDirectory.path) {
72
+ try FileManager.default.removeItem(at: outputDirectory)
73
+ }
74
+
75
+ try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
76
+
77
+ try writeRootContents()
78
+ try writePrimaryIconStack(
79
+ name: "App Icon - tvOS", canvas: smallIconCanvas, scales: [1, 2], includeFrameSize: true)
80
+ try writePrimaryIconStack(
81
+ name: "App Icon - tvOS - App Store", canvas: appStoreCanvas, scales: [1],
82
+ includeFrameSize: false)
83
+ try writeTopShelfImageSet(
84
+ name: "Top Shelf Image", canvas: topShelfCanvas, baseFileName: "TopShelfImage")
85
+ try writeTopShelfImageSet(
86
+ name: "Top Shelf Image Wide", canvas: topShelfWideCanvas, baseFileName: "TopShelfImageWide")
87
+}
88
+
89
+private func defaultOutputDirectoryPath() -> String {
90
+ let cwd = FileManager.default.currentDirectoryPath
91
+ return URL(fileURLWithPath: cwd)
92
+ .appendingPathComponent("Klepon/Assets.xcassets/\(catalogName)")
93
+ .path
94
+}
95
+
96
+private func writeRootContents() throws {
97
+ try writeJSON(
98
+ [
99
+ "assets": [
100
+ [
101
+ "idiom": "tv",
102
+ "filename": "App Icon - tvOS - App Store.imagestack",
103
+ "role": "primary-app-icon",
104
+ "size": "1280x768",
105
+ ],
106
+ [
107
+ "idiom": "tv",
108
+ "filename": "App Icon - tvOS.imagestack",
109
+ "role": "primary-app-icon",
110
+ "size": "400x240",
111
+ ],
112
+ [
113
+ "idiom": "tv",
114
+ "filename": "Top Shelf Image.imageset",
115
+ "role": "top-shelf-image",
116
+ "size": "1920x720",
117
+ ],
118
+ [
119
+ "idiom": "tv",
120
+ "filename": "Top Shelf Image Wide.imageset",
121
+ "role": "top-shelf-image-wide",
122
+ "size": "2320x720",
123
+ ],
124
+ ],
125
+ "info": [
126
+ "author": bundleIdentifier,
127
+ "version": 1,
128
+ ],
129
+ ],
130
+ to: outputDirectory.appendingPathComponent("Contents.json")
131
+ )
132
+}
133
+
134
+private func writePrimaryIconStack(
135
+ name: String, canvas: CGSize, scales: [Int], includeFrameSize: Bool
136
+) throws {
137
+ let stackFolder = outputDirectory.appendingPathComponent("\(name).imagestack")
138
+ try FileManager.default.createDirectory(at: stackFolder, withIntermediateDirectories: true)
139
+
140
+ try writeJSON(
141
+ [
142
+ "info": [
143
+ "author": bundleIdentifier,
144
+ "version": 1,
145
+ ],
146
+ "layers": [
147
+ ["filename": "Front.imagestacklayer"],
148
+ ["filename": "Back.imagestacklayer"],
149
+ ],
150
+ ],
151
+ to: stackFolder.appendingPathComponent("Contents.json")
152
+ )
153
+
154
+ try writeLayer(
155
+ folder: stackFolder.appendingPathComponent("Front.imagestacklayer"),
156
+ imageSetName: "Content",
157
+ imageBaseName: "Front",
158
+ canvas: canvas,
159
+ scales: scales,
160
+ includeFrameSize: includeFrameSize,
161
+ render: { try renderIconFront(canvas: $0) }
162
+ )
163
+
164
+ try writeLayer(
165
+ folder: stackFolder.appendingPathComponent("Back.imagestacklayer"),
166
+ imageSetName: "Content",
167
+ imageBaseName: "Back",
168
+ canvas: canvas,
169
+ scales: scales,
170
+ includeFrameSize: includeFrameSize,
171
+ render: { try renderIconBack(canvas: $0) }
172
+ )
173
+}
174
+
175
+private func writeLayer(
176
+ folder: URL,
177
+ imageSetName: String,
178
+ imageBaseName: String,
179
+ canvas: CGSize,
180
+ scales: [Int],
181
+ includeFrameSize: Bool,
182
+ render: (CGSize) throws -> Data
183
+) throws {
184
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
185
+
186
+ var object: [String: Any] = [
187
+ "info": [
188
+ "author": bundleIdentifier,
189
+ "version": 1,
190
+ ]
191
+ ]
192
+
193
+ if includeFrameSize {
194
+ object["properties"] = [
195
+ "frame-size": [
196
+ "width": Int(canvas.width),
197
+ "height": Int(canvas.height),
198
+ ]
199
+ ]
200
+ }
201
+
202
+ try writeJSON(object, to: folder.appendingPathComponent("Contents.json"))
203
+
204
+ try writeImageSet(
205
+ folder: folder.appendingPathComponent("\(imageSetName).imageset"),
206
+ baseName: imageBaseName,
207
+ canvas: canvas,
208
+ scales: scales,
209
+ render: render
210
+ )
211
+}
212
+
213
+private func writeTopShelfImageSet(name: String, canvas: CGSize, baseFileName: String) throws {
214
+ try writeImageSet(
215
+ folder: outputDirectory.appendingPathComponent("\(name).imageset"),
216
+ baseName: baseFileName,
217
+ canvas: canvas,
218
+ scales: [1, 2],
219
+ render: { try renderTopShelf(canvas: $0, isWide: name.contains("Wide")) }
220
+ )
221
+}
222
+
223
+private func writeImageSet(
224
+ folder: URL,
225
+ baseName: String,
226
+ canvas: CGSize,
227
+ scales: [Int],
228
+ render: (CGSize) throws -> Data
229
+) throws {
230
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
231
+
232
+ var imageEntries: [[String: String]] = []
233
+
234
+ for scale in scales {
235
+ let scaledCanvas = CGSize(
236
+ width: canvas.width * CGFloat(scale), height: canvas.height * CGFloat(scale))
237
+ let fileName = scale == 1 ? "\(baseName).png" : "\(baseName)@\(scale)x.png"
238
+ let png = try render(scaledCanvas)
239
+ try png.write(to: folder.appendingPathComponent(fileName), options: .atomic)
240
+
241
+ var entry: [String: String] = [
242
+ "filename": fileName,
243
+ "idiom": "tv",
244
+ ]
245
+
246
+ if scales.count > 1 {
247
+ entry["scale"] = "\(scale)x"
248
+ }
249
+
250
+ imageEntries.append(entry)
251
+ }
252
+
253
+ try writeJSON(
254
+ [
255
+ "images": imageEntries,
256
+ "info": [
257
+ "author": bundleIdentifier,
258
+ "version": 1,
259
+ ],
260
+ ],
261
+ to: folder.appendingPathComponent("Contents.json")
262
+ )
263
+}
264
+
265
+private func renderIconBack(canvas: CGSize) throws -> Data {
266
+ try renderPNG(canvas: canvas, opaque: true) { rect in
267
+ drawWarmBackdrop(in: rect)
268
+
269
+ let cardRect = CGRect(
270
+ x: rect.minX + rect.width * 0.08,
271
+ y: rect.minY + rect.height * 0.08,
272
+ width: rect.width * 0.84,
273
+ height: rect.height * 0.84
274
+ )
275
+ drawCard(in: cardRect, canvas: canvas)
276
+
277
+ let haloRect = CGRect(
278
+ x: rect.midX - rect.height * 0.23,
279
+ y: rect.minY + rect.height * 0.24,
280
+ width: rect.height * 0.46,
281
+ height: rect.height * 0.46
282
+ )
283
+ let halo = NSBezierPath(ovalIn: haloRect)
284
+ NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 0.12).setFill()
285
+ halo.fill()
286
+ }
287
+}
288
+
289
+private func renderIconFront(canvas: CGSize) throws -> Data {
290
+ try renderPNG(canvas: canvas) { rect in
291
+ let orbSize = min(rect.height * 0.70, rect.width * 0.54)
292
+ let orbRect = CGRect(
293
+ x: rect.midX - orbSize / 2,
294
+ y: rect.minY + rect.height * 0.18,
295
+ width: orbSize,
296
+ height: orbSize
297
+ )
298
+ drawKlepon(in: orbRect, canvas: canvas, shadowAlpha: 0.34)
299
+ }
300
+}
301
+
302
+private func renderTopShelf(canvas: CGSize, isWide: Bool) throws -> Data {
303
+ try renderPNG(canvas: canvas, opaque: true) { rect in
304
+ drawWarmBackdrop(in: rect)
305
+
306
+ let wave = NSBezierPath()
307
+ wave.move(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.14))
308
+ wave.curve(
309
+ to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.22),
310
+ controlPoint1: CGPoint(
311
+ x: rect.minX + rect.width * 0.22, y: rect.minY + rect.height * 0.04),
312
+ controlPoint2: CGPoint(
313
+ x: rect.minX + rect.width * 0.68, y: rect.minY + rect.height * 0.30)
314
+ )
315
+ wave.line(to: CGPoint(x: rect.maxX, y: rect.minY))
316
+ wave.line(to: CGPoint(x: rect.minX, y: rect.minY))
317
+ wave.close()
318
+ NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 0.24).setFill()
319
+ wave.fill()
320
+
321
+ let orbGlow = NSBezierPath(
322
+ ovalIn: CGRect(
323
+ x: rect.maxX - rect.height * 0.78,
324
+ y: rect.midY - rect.height * 0.34,
325
+ width: rect.height * 0.92,
326
+ height: rect.height * 0.92
327
+ ))
328
+ NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 0.10).setFill()
329
+ orbGlow.fill()
330
+
331
+ let titleWidth = isWide ? rect.width * 0.30 : rect.width * 0.34
332
+ drawText(
333
+ "Taste of Indonesia",
334
+ in: CGRect(
335
+ x: rect.width * 0.10, y: rect.height * 0.62, width: titleWidth,
336
+ height: rect.height * 0.10),
337
+ font: font(name: "Avenir Next Demi Bold", size: rect.height * 0.09)
338
+ ?? NSFont.systemFont(ofSize: rect.height * 0.09, weight: .semibold),
339
+ color: textSecondaryColor
340
+ )
341
+
342
+ drawText(
343
+ "Klepon",
344
+ in: CGRect(
345
+ x: rect.width * 0.10, y: rect.height * 0.38, width: rect.width * 0.30,
346
+ height: rect.height * 0.18),
347
+ font: font(name: "Avenir Next Bold", size: rect.height * 0.20)
348
+ ?? NSFont.boldSystemFont(ofSize: rect.height * 0.20),
349
+ color: textPrimaryColor
350
+ )
351
+
352
+ drawText(
353
+ "Dishes Ingredients Traditions",
354
+ in: CGRect(
355
+ x: rect.width * 0.10, y: rect.height * 0.26, width: rect.width * 0.46,
356
+ height: rect.height * 0.08),
357
+ font: font(name: "Avenir Next Regular", size: rect.height * 0.075)
358
+ ?? NSFont.systemFont(ofSize: rect.height * 0.075),
359
+ color: textSecondaryColor
360
+ )
361
+
362
+ let cardWidth = isWide ? rect.width * 0.18 : rect.width * 0.23
363
+ let cardRect = CGRect(
364
+ x: rect.maxX - cardWidth - rect.width * 0.16,
365
+ y: rect.midY - rect.height * 0.28,
366
+ width: cardWidth,
367
+ height: rect.height * 0.56
368
+ )
369
+ drawCard(in: cardRect, canvas: canvas)
370
+
371
+ let secondaryCard = NSBezierPath(
372
+ roundedRect: CGRect(
373
+ x: cardRect.minX - cardRect.width * 0.18,
374
+ y: cardRect.minY + cardRect.height * 0.18,
375
+ width: cardRect.width * 0.34,
376
+ height: cardRect.height * 0.48
377
+ ), xRadius: cardRect.width * 0.11, yRadius: cardRect.width * 0.11)
378
+ NSColor(calibratedWhite: 1, alpha: 0.28).setFill()
379
+ secondaryCard.fill()
380
+
381
+ let tertiaryCard = NSBezierPath(
382
+ roundedRect: CGRect(
383
+ x: cardRect.maxX - cardRect.width * 0.02,
384
+ y: cardRect.minY + cardRect.height * 0.08,
385
+ width: cardRect.width * 0.26,
386
+ height: cardRect.height * 0.36
387
+ ), xRadius: cardRect.width * 0.09, yRadius: cardRect.width * 0.09)
388
+ NSColor(calibratedWhite: 1, alpha: 0.18).setFill()
389
+ tertiaryCard.fill()
390
+
391
+ let orbSize = min(cardRect.width * 0.68, cardRect.height * 0.68)
392
+ let orbRect = CGRect(
393
+ x: cardRect.midX - orbSize / 2,
394
+ y: cardRect.minY + cardRect.height * 0.24,
395
+ width: orbSize,
396
+ height: orbSize
397
+ )
398
+ drawKlepon(in: orbRect, canvas: canvas, shadowAlpha: 0.28)
399
+ }
400
+}
401
+
402
+private func drawWarmBackdrop(in rect: CGRect) {
403
+ let background = NSGradient(colors: [
404
+ NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.94, alpha: 1),
405
+ backgroundColor,
406
+ NSColor(calibratedRed: 0.95, green: 0.91, blue: 0.84, alpha: 1),
407
+ ])!
408
+ background.draw(in: rect, angle: 0)
409
+
410
+ let topGlow = NSBezierPath(
411
+ ovalIn: CGRect(
412
+ x: rect.minX - rect.width * 0.08,
413
+ y: rect.maxY - rect.height * 0.46,
414
+ width: rect.width * 0.52,
415
+ height: rect.height * 0.52
416
+ ))
417
+ NSColor(calibratedWhite: 1, alpha: 0.14).setFill()
418
+ topGlow.fill()
419
+
420
+ let rightGlow = NSBezierPath(
421
+ ovalIn: CGRect(
422
+ x: rect.maxX - rect.width * 0.26,
423
+ y: rect.minY + rect.height * 0.18,
424
+ width: rect.width * 0.24,
425
+ height: rect.height * 0.44
426
+ ))
427
+ NSColor(calibratedRed: 0.90, green: 0.76, blue: 0.42, alpha: 0.10).setFill()
428
+ rightGlow.fill()
429
+}
430
+
431
+private func drawCard(in rect: CGRect, canvas: CGSize) {
432
+ let radius = min(rect.width, rect.height) * 0.18
433
+ let card = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
434
+ let cardGradient = NSGradient(colors: [
435
+ NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.93, alpha: 1),
436
+ surfaceColor,
437
+ surfaceSecondaryColor,
438
+ ])!
439
+ cardGradient.draw(in: card, angle: 90)
440
+
441
+ dividerColor.setStroke()
442
+ card.lineWidth = max(1, canvas.width * 0.0016)
443
+ card.stroke()
444
+
445
+ let innerGlow = NSBezierPath(
446
+ roundedRect: rect.insetBy(dx: rect.width * 0.02, dy: rect.height * 0.02),
447
+ xRadius: radius * 0.92, yRadius: radius * 0.92)
448
+ NSColor(calibratedWhite: 1, alpha: 0.22).setStroke()
449
+ innerGlow.lineWidth = max(1.5, canvas.width * 0.0022)
450
+ innerGlow.stroke()
451
+
452
+ NSGraphicsContext.saveGraphicsState()
453
+ card.addClip()
454
+ let cornerGlow = NSGradient(colors: [
455
+ NSColor(calibratedWhite: 1, alpha: 0.24),
456
+ NSColor(calibratedWhite: 1, alpha: 0),
457
+ ])!
458
+ cornerGlow.draw(
459
+ fromCenter: CGPoint(x: rect.minX + rect.width * 0.24, y: rect.maxY - rect.height * 0.18),
460
+ radius: rect.width * 0.58,
461
+ toCenter: CGPoint(x: rect.minX + rect.width * 0.24, y: rect.maxY - rect.height * 0.18),
462
+ radius: 0,
463
+ options: []
464
+ )
465
+ NSGraphicsContext.restoreGraphicsState()
466
+}
467
+
468
+private func drawKlepon(in orbRect: CGRect, canvas: CGSize, shadowAlpha: CGFloat) {
469
+ let shadowRect = CGRect(
470
+ x: orbRect.minX + orbRect.width * 0.06,
471
+ y: orbRect.minY - orbRect.height * 0.12,
472
+ width: orbRect.width * 0.88,
473
+ height: orbRect.height * 0.26
474
+ )
475
+ let shadow = NSBezierPath(ovalIn: shadowRect)
476
+ NSColor(calibratedRed: 0.37, green: 0.24, blue: 0.10, alpha: shadowAlpha).setFill()
477
+ shadow.fill()
478
+
479
+ let orb = NSBezierPath(ovalIn: orbRect)
480
+ let orbGradient = NSGradient(colors: [
481
+ accentLightColor, accentColor,
482
+ NSColor(calibratedRed: 0.28, green: 0.58, blue: 0.40, alpha: 1),
483
+ ])!
484
+ orbGradient.draw(in: orb, angle: 18)
485
+
486
+ let shade = NSBezierPath(
487
+ ovalIn: CGRect(
488
+ x: orbRect.minX + orbRect.width * 0.06,
489
+ y: orbRect.minY + orbRect.height * 0.10,
490
+ width: orbRect.width * 0.74,
491
+ height: orbRect.height * 0.70
492
+ ))
493
+ let shadeGradient = NSGradient(colors: [
494
+ NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.12, alpha: 0.26),
495
+ NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.12, alpha: 0),
496
+ ])!
497
+ shadeGradient.draw(in: shade, relativeCenterPosition: NSPoint(x: -0.3, y: -0.1))
498
+
499
+ let highlight = NSBezierPath(
500
+ ovalIn: CGRect(
501
+ x: orbRect.minX + orbRect.width * 0.16,
502
+ y: orbRect.minY + orbRect.height * 0.58,
503
+ width: orbRect.width * 0.30,
504
+ height: orbRect.height * 0.18
505
+ ))
506
+ let highlightGradient = NSGradient(colors: [
507
+ NSColor(calibratedWhite: 1, alpha: 0.28),
508
+ NSColor(calibratedWhite: 1, alpha: 0),
509
+ ])!
510
+ highlightGradient.draw(in: highlight, angle: 0)
511
+
512
+ drawLeaves(around: orbRect, canvas: canvas)
513
+ drawPalmSugar(in: orbRect, canvas: canvas)
514
+ drawCoconutFlakes(in: orbRect, canvas: canvas)
515
+}
516
+
517
+private func drawLeaves(around orbRect: CGRect, canvas: CGSize) {
518
+ let darkLeafRect = CGRect(
519
+ x: orbRect.maxX - orbRect.width * 0.28,
520
+ y: orbRect.maxY - orbRect.height * 0.18,
521
+ width: orbRect.width * 0.22,
522
+ height: orbRect.height * 0.22
523
+ )
524
+ let darkLeaf = NSBezierPath(ovalIn: darkLeafRect)
525
+ accentDarkColor.setFill()
526
+ darkLeaf.fill()
527
+
528
+ let lightLeafRect = CGRect(
529
+ x: orbRect.maxX - orbRect.width * 0.34,
530
+ y: orbRect.maxY - orbRect.height * 0.23,
531
+ width: orbRect.width * 0.18,
532
+ height: orbRect.height * 0.18
533
+ )
534
+ let lightLeaf = NSBezierPath(ovalIn: lightLeafRect)
535
+ NSColor(calibratedRed: 0.30, green: 0.62, blue: 0.39, alpha: 1).setFill()
536
+ lightLeaf.fill()
537
+
538
+ let stem = NSBezierPath()
539
+ stem.move(
540
+ to: CGPoint(x: orbRect.maxX - orbRect.width * 0.16, y: orbRect.maxY - orbRect.height * 0.08)
541
+ )
542
+ stem.line(
543
+ to: CGPoint(x: orbRect.maxX - orbRect.width * 0.16, y: orbRect.maxY - orbRect.height * 0.34)
544
+ )
545
+ stem.lineCapStyle = .round
546
+ stem.lineWidth = max(2.2, canvas.width * 0.006)
547
+ NSColor(calibratedRed: 0.23, green: 0.56, blue: 0.33, alpha: 1).setStroke()
548
+ stem.stroke()
549
+}
550
+
551
+private func drawPalmSugar(in orbRect: CGRect, canvas: CGSize) {
552
+ let sugarSize = orbRect.width * 0.24
553
+ let sugarRect = CGRect(
554
+ x: orbRect.midX - sugarSize / 2,
555
+ y: orbRect.midY - sugarSize / 2 + orbRect.height * 0.02,
556
+ width: sugarSize,
557
+ height: sugarSize
558
+ )
559
+ let sugarGlow = NSBezierPath(
560
+ ovalIn: sugarRect.insetBy(dx: -sugarSize * 0.16, dy: -sugarSize * 0.16))
561
+ NSColor(calibratedRed: 0.24, green: 0.14, blue: 0.06, alpha: 0.28).setFill()
562
+ sugarGlow.fill()
563
+
564
+ let sugar = NSBezierPath(ovalIn: sugarRect)
565
+ let sugarGradient = NSGradient(colors: [
566
+ warmLightColor, NSColor(calibratedRed: 0.83, green: 0.47, blue: 0.16, alpha: 1), warmColor,
567
+ ])!
568
+ sugarGradient.draw(in: sugar, relativeCenterPosition: NSPoint(x: 0.18, y: -0.18))
569
+
570
+ NSColor(calibratedWhite: 1, alpha: 0.16).setStroke()
571
+ sugar.lineWidth = max(1, canvas.width * 0.0022)
572
+ sugar.stroke()
573
+}
574
+
575
+private func drawCoconutFlakes(in orbRect: CGRect, canvas: CGSize) {
576
+ for flake in coconutFlakes {
577
+ let flakeRect = CGRect(
578
+ x: orbRect.minX + orbRect.width * flake.0,
579
+ y: orbRect.minY + orbRect.height * flake.1,
580
+ width: orbRect.width * flake.2,
581
+ height: orbRect.height * flake.3
582
+ )
583
+
584
+ let shadowRect = flakeRect.offsetBy(dx: orbRect.width * 0.008, dy: -orbRect.height * 0.008)
585
+ let shadow = NSBezierPath(ovalIn: shadowRect)
586
+ coconutShadowColor.setFill()
587
+ shadow.fill()
588
+
589
+ NSGraphicsContext.saveGraphicsState()
590
+ if let cgContext = NSGraphicsContext.current?.cgContext {
591
+ cgContext.translateBy(x: flakeRect.midX, y: flakeRect.midY)
592
+ cgContext.rotate(by: flake.4 * .pi / 180)
593
+ cgContext.translateBy(x: -flakeRect.midX, y: -flakeRect.midY)
594
+ }
595
+
596
+ let flakePath = NSBezierPath(ovalIn: flakeRect)
597
+ coconutColor.setFill()
598
+ flakePath.fill()
599
+ NSColor(calibratedWhite: 1, alpha: 0.26).setStroke()
600
+ flakePath.lineWidth = max(0.4, canvas.width * 0.0009)
601
+ flakePath.stroke()
602
+ NSGraphicsContext.restoreGraphicsState()
603
+ }
604
+}
605
+
606
+private func renderPNG(canvas: CGSize, opaque: Bool = false, draw: (CGRect) throws -> Void) throws
607
+ -> Data
608
+{
609
+ let pixelsWide = Int(canvas.width)
610
+ let pixelsHigh = Int(canvas.height)
611
+
612
+ guard
613
+ let rep = NSBitmapImageRep(
614
+ bitmapDataPlanes: nil,
615
+ pixelsWide: pixelsWide,
616
+ pixelsHigh: pixelsHigh,
617
+ bitsPerSample: 8,
618
+ samplesPerPixel: 4,
619
+ hasAlpha: true,
620
+ isPlanar: false,
621
+ colorSpaceName: .deviceRGB,
622
+ bitmapFormat: [],
623
+ bytesPerRow: 0,
624
+ bitsPerPixel: 0
625
+ )
626
+ else {
627
+ throw NSError(
628
+ domain: "BrandAssetGenerator",
629
+ code: 1,
630
+ userInfo: [
631
+ NSLocalizedDescriptionKey:
632
+ "Unable to allocate bitmap for \(pixelsWide)x\(pixelsHigh)"
633
+ ]
634
+ )
635
+ }
636
+
637
+ rep.size = canvas
638
+
639
+ guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
640
+ throw NSError(
641
+ domain: "BrandAssetGenerator",
642
+ code: 2,
643
+ userInfo: [NSLocalizedDescriptionKey: "Unable to create bitmap graphics context"]
644
+ )
645
+ }
646
+
647
+ NSGraphicsContext.saveGraphicsState()
648
+ NSGraphicsContext.current = context
649
+ context.shouldAntialias = true
650
+ context.imageInterpolation = .high
651
+ context.cgContext.setAllowsAntialiasing(true)
652
+ context.cgContext.setShouldAntialias(true)
653
+
654
+ if opaque {
655
+ backgroundColor.setFill()
656
+ CGRect(origin: .zero, size: canvas).fill()
657
+ }
658
+
659
+ try draw(CGRect(origin: .zero, size: canvas))
660
+ context.flushGraphics()
661
+ NSGraphicsContext.restoreGraphicsState()
662
+
663
+ guard let png = rep.representation(using: .png, properties: [:]) else {
664
+ throw NSError(
665
+ domain: "BrandAssetGenerator",
666
+ code: 3,
667
+ userInfo: [NSLocalizedDescriptionKey: "Failed to encode PNG"]
668
+ )
669
+ }
670
+
671
+ return png
672
+}
673
+
674
+private func writeJSON(_ object: [String: Any], to url: URL) throws {
675
+ let data = try JSONSerialization.data(
676
+ withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
677
+ try data.write(to: url, options: .atomic)
678
+}
679
+
680
+private func drawText(_ string: String, in rect: CGRect, font: NSFont, color: NSColor) {
681
+ let paragraph = NSMutableParagraphStyle()
682
+ paragraph.alignment = .left
683
+ paragraph.lineBreakMode = .byClipping
684
+
685
+ let attributed = NSAttributedString(
686
+ string: string,
687
+ attributes: [
688
+ .font: font,
689
+ .foregroundColor: color,
690
+ .paragraphStyle: paragraph,
691
+ ]
692
+ )
693
+
694
+ attributed.draw(in: rect)
695
+}
696
+
697
+private func font(name: String, size: CGFloat) -> NSFont? {
698
+ NSFont(name: name, size: size)
699
+}
project.yml
+108
-3
@@ -13,7 +13,7 @@ options:
13
packages:
14
Onde:
15
url: https://github.com/ondeinference/onde-swift
16
- exactVersion: 1.1.0
16
+ exactVersion: 1.1.2
17
SnapshotTesting:
18
url: https://github.com/pointfreeco/swift-snapshot-testing
19
from: 1.19.2
@@ -100,6 +100,10 @@ targets:
100
CFBundleShortVersionString: $(MARKETING_VERSION)
101
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
102
ITSAppUsesNonExemptEncryption: false
103
+ OndeWebDevHost: $(ONDE_WEB_DEV_HOST)
104
+ OndeWebDevPort: $(ONDE_WEB_DEV_PORT)
105
+ OndeAppId: $(ONDE_APP_ID)
106
+ OndeAppSecret: $(ONDE_APP_SECRET)
107
UILaunchScreen: {}
108
UIApplicationSceneManifest:
109
UIApplicationSupportsMultipleScenes: false
@@ -121,8 +125,8 @@ targets:
125
com.apple.security.application-groups:
126
- group.com.ondeinference.apps
127
configFiles:
124
- Debug: Configs/Signing.xcconfig
125
- Release: Configs/Signing.xcconfig
128
+ Debug: Configs/Debug.xcconfig
129
+ Release: Configs/Release.xcconfig
130
dependencies:
131
- package: Onde
132
product: Onde
@@ -187,11 +191,14 @@ targets:
191
CURRENT_PROJECT_VERSION: 1
192
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
193
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
194
+ DEAD_CODE_STRIPPING: YES
195
ENABLE_USER_SCRIPT_SANDBOXING: NO
196
TARGETED_DEVICE_FAMILY: "1,2"
197
"EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64
198
configs:
199
Debug:
200
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon-Debug
201
+ PRODUCT_NAME: Klepon Debug
202
SWIFT_OPTIMIZATION_LEVEL: -Onone
203
Release:
204
SWIFT_OPTIMIZATION_LEVEL: -O
@@ -235,6 +242,8 @@ targets:
242
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
243
ITSAppUsesNonExemptEncryption: false
244
LSApplicationCategoryType: public.app-category.food-and-drink
245
+ OndeAppId: $(ONDE_APP_ID)
246
+ OndeAppSecret: $(ONDE_APP_SECRET)
247
entitlements:
248
path: KleponMac/KleponMac.entitlements
249
properties:
@@ -308,6 +317,7 @@ targets:
317
PRODUCT_NAME: Klepon
318
MARKETING_VERSION: "1.0.0"
319
CURRENT_PROJECT_VERSION: 1
320
+ DEAD_CODE_STRIPPING: YES
321
ENABLE_USER_SCRIPT_SANDBOXING: NO
322
ASSETCATALOG_COMPILER_APPICON_NAME: AppIconMac
323
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
@@ -315,6 +325,8 @@ targets:
325
"EXCLUDED_ARCHS[sdk=macosx*]": x86_64
326
configs:
327
Debug:
328
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon-Debug
329
+ PRODUCT_NAME: Klepon Debug
330
SWIFT_OPTIMIZATION_LEVEL: -Onone
331
Release:
332
SWIFT_OPTIMIZATION_LEVEL: -O
@@ -328,6 +340,9 @@ targets:
340
excludes:
341
- Info.plist
342
- Klepon.entitlements
343
+ - App/KleponApp.swift
344
+ - App/AppRootView.swift
345
+ - path: KleponTV/App
346
info:
347
path: KleponTV/Info.plist
348
properties:
@@ -335,9 +350,22 @@ targets:
350
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
351
ITSAppUsesNonExemptEncryption: false
352
UILaunchScreen: {}
353
+ OndeAppId: $(ONDE_APP_ID)
354
+ OndeAppSecret: $(ONDE_APP_SECRET)
355
+ CFBundleURLTypes:
356
+ - CFBundleURLName: ai.splitfire.klepon.tvos
357
+ CFBundleURLSchemes:
358
+ - klepon
359
+ UIApplicationSceneManifest:
360
+ UIApplicationSupportsMultipleScenes: false
361
+ UISceneConfigurations:
362
+ UIWindowSceneSessionRoleApplication:
363
+ - UISceneConfigurationName: Default Configuration
364
+ UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate
365
entitlements:
366
path: KleponTV/KleponTV.entitlements
367
properties:
368
+ com.apple.developer.kernel.increased-memory-limit: true
369
com.apple.security.application-groups:
370
- group.com.ondeinference.apps
371
configFiles:
@@ -346,6 +374,8 @@ targets:
374
dependencies:
375
- package: Onde
376
product: Onde
377
+ - target: Klepon TV Top Shelf
378
+ embed: true
379
postBuildScripts:
380
- name: Update CFBundleVersion
381
shell: /bin/sh
@@ -387,6 +417,74 @@ targets:
417
"EXCLUDED_ARCHS[sdk=appletvsimulator*]": x86_64
418
configs:
419
Debug:
420
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon-Debug
421
+ PRODUCT_NAME: Klepon Debug
422
+ SWIFT_OPTIMIZATION_LEVEL: -Onone
423
+ Release:
424
+ SWIFT_OPTIMIZATION_LEVEL: -O
425
+
426
+ KleponTVTopShelfExtension:
427
+ name: Klepon TV Top Shelf
428
+ type: app-extension
429
+ platform: tvOS
430
+ sources:
431
+ - path: KleponTVTopShelfExtension
432
+ excludes:
433
+ - Info.plist
434
+ info:
435
+ path: KleponTVTopShelfExtension/Info.plist
436
+ properties:
437
+ CFBundleShortVersionString: $(MARKETING_VERSION)
438
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
439
+ CFBundleDisplayName: Klepon TV Top Shelf
440
+ UIRequiredDeviceCapabilities:
441
+ - arm64
442
+ NSExtension:
443
+ NSExtensionPointIdentifier: com.apple.tv-top-shelf
444
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ContentProvider
445
+ configFiles:
446
+ Debug: Configs/Signing.xcconfig
447
+ Release: Configs/Signing.xcconfig
448
+ postBuildScripts:
449
+ - name: Update CFBundleVersion
450
+ shell: /bin/sh
451
+ basedOnDependencyAnalysis: false
452
+ showEnvVars: false
453
+ script: |
454
+ set -eu
455
+
456
+ SHARED_BUNDLE_VERSION_FILE="${BUILD_DIR}/klepon-cfbundleversion.txt"
457
+ if [ -f "${SHARED_BUNDLE_VERSION_FILE}" ]; then
458
+ BUNDLE_VERSION="$(cat "${SHARED_BUNDLE_VERSION_FILE}")"
459
+ else
460
+ BUNDLE_VERSION="$(date -u +"%Y%m%d.%H%M")"
461
+ printf '%s' "${BUNDLE_VERSION}" > "${SHARED_BUNDLE_VERSION_FILE}"
462
+ fi
463
+
464
+ INFO_PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
465
+
466
+ if [ ! -f "${INFO_PLIST}" ]; then
467
+ echo "error: Info.plist not found at ${INFO_PLIST}"
468
+ exit 1
469
+ fi
470
+
471
+ if ! /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${BUNDLE_VERSION}" "${INFO_PLIST}" >/dev/null 2>&1; then
472
+ /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string ${BUNDLE_VERSION}" "${INFO_PLIST}"
473
+ fi
474
+
475
+ echo "Updated CFBundleVersion to ${BUNDLE_VERSION}"
476
+ settings:
477
+ base:
478
+ APPLICATION_EXTENSION_API_ONLY: YES
479
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon.topshelf
480
+ PRODUCT_NAME: Klepon TV Top Shelf
481
+ MARKETING_VERSION: "1.0.0"
482
+ CURRENT_PROJECT_VERSION: 1
483
+ ENABLE_USER_SCRIPT_SANDBOXING: NO
484
+ TARGETED_DEVICE_FAMILY: 3
485
+ configs:
486
+ Debug:
487
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon-Debug.topshelf
488
SWIFT_OPTIMIZATION_LEVEL: -Onone
489
Release:
490
SWIFT_OPTIMIZATION_LEVEL: -O
@@ -407,6 +505,8 @@ targets:
505
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
506
ITSAppUsesNonExemptEncryption: false
507
UILaunchScreen: {}
508
+ OndeAppId: $(ONDE_APP_ID)
509
+ OndeAppSecret: $(ONDE_APP_SECRET)
510
entitlements:
511
path: KleponVision/KleponVision.entitlements
512
properties:
@@ -459,6 +559,8 @@ targets:
559
"EXCLUDED_ARCHS[sdk=xrsimulator*]": x86_64
560
configs:
561
Debug:
562
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon-Debug
563
+ PRODUCT_NAME: Klepon Debug
564
SWIFT_OPTIMIZATION_LEVEL: -Onone
565
Release:
566
SWIFT_OPTIMIZATION_LEVEL: -O
@@ -507,6 +609,7 @@ targets:
609
settings:
610
base:
611
PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon.watchkitapp
612
+ KLEPON_COMPANION_BUNDLE_ID: ai.splitfire.klepon
613
PRODUCT_NAME: Klepon Watch
614
MARKETING_VERSION: "1.0.0"
615
CURRENT_PROJECT_VERSION: 1
@@ -525,6 +628,8 @@ targets:
628
"EXCLUDED_ARCHS[sdk=watchsimulator*]": x86_64
629
configs:
630
Debug:
631
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.klepon-Debug.watchkitapp
632
+ KLEPON_COMPANION_BUNDLE_ID: ai.splitfire.klepon-Debug
633
SWIFT_OPTIMIZATION_LEVEL: -Onone
634
Release:
635
SWIFT_OPTIMIZATION_LEVEL: -O