@ondeinference / klepon / commits / d249bed

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