11import SwiftUI
2+ import Combine
23
3- // MARK: - Models (AltStore-ish )
4+ // MARK: - Models (decodable for AltStore source format )
45struct AltSource : Decodable {
56 let name : String ?
67 let subtitle : String ?
@@ -9,15 +10,16 @@ struct AltSource: Decodable {
910}
1011
1112struct AltApp : Decodable , Identifiable {
13+ // Use bundleIdentifier as stable id
1214 var id : String { bundleIdentifier }
1315 let name : String
1416 let bundleIdentifier : String
1517 let developerName : String ?
1618 let subtitle : String ?
1719 let iconURL : URL ?
18- let localizedDescription : String ?
1920 let versions : [ AppVersion ] ?
2021
22+ // A convenience computed property for a primary download URL (if present on the latest version)
2123 var latestDownloadURL : URL ? {
2224 versions? . first? . downloadURL
2325 }
@@ -47,54 +49,65 @@ final class RepoViewModel: ObservableObject {
4749 Task { await load ( ) }
4850 }
4951
52+ /// Load JSON and decode apps. Tries both `{ "apps": [...] }` and direct `[App]` shapes.
5053 func load( ) async {
5154 isLoading = true
5255 errorMessage = nil
5356 defer { isLoading = false }
5457
5558 do {
5659 var request = URLRequest ( url: sourceURL)
57- request. setValue ( " ProStore/1.0 (iOS) " , forHTTPHeaderField: " User-Agent " )
60+ // Some repos expect a GET; set a reasonable user-agent
61+ request. setValue ( " AppTestersListView/1.0 (iOS) " , forHTTPHeaderField: " User-Agent " )
62+
5863 let ( data, response) = try await URLSession . shared. data ( for: request)
5964 if let http = response as? HTTPURLResponse , !( 200 ... 299 ) . contains ( http. statusCode) {
6065 throw NSError ( domain: " RepoFetcher " , code: http. statusCode, userInfo: [ NSLocalizedDescriptionKey: " HTTP \( http. statusCode) " ] )
6166 }
6267
6368 let decoder = JSONDecoder ( )
64- // try common shapes
69+ // First try decoding top-level AltSource ( common altstore format)
6570 if let source = try ? decoder. decode ( AltSource . self, from: data) , let apps = source. apps {
6671 self . apps = apps
6772 return
6873 }
74+
75+ // Fallback: some repos publish a raw array of apps
6976 if let appsArray = try ? decoder. decode ( [ AltApp ] . self, from: data) {
7077 self . apps = appsArray
7178 return
7279 }
73- // fallbacks: try extracting "apps" key manually
74- if let jsonObject = try JSONSerialization . jsonObject ( with: data) as? [ String : Any ] ,
75- let appsFragment = jsonObject [ " apps " ] {
76- let fragmentData = try JSONSerialization . data ( withJSONObject: appsFragment)
77- let appsArray = try decoder. decode ( [ AltApp ] . self, from: fragmentData)
78- self . apps = appsArray
79- return
80+
81+ // Another fallback: some repos wrap apps in a top-level dictionary under other keys
82+ if let jsonObject = try JSONSerialization . jsonObject ( with: data) as? [ String : Any ] {
83+ // try to extract "apps" key and re-decode that fragment
84+ if let appsFragment = jsonObject [ " apps " ] {
85+ let fragmentData = try JSONSerialization . data ( withJSONObject: appsFragment)
86+ let appsArray = try decoder. decode ( [ AltApp ] . self, from: fragmentData)
87+ self . apps = appsArray
88+ return
89+ }
8090 }
81- throw NSError ( domain: " RepoFetcher " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " Unexpected JSON format " ] )
91+
92+ throw NSError ( domain: " RepoFetcher " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " Unexpected JSON format. " ] )
93+
8294 } catch {
83- self . errorMessage = " Failed to load: \( error. localizedDescription) "
95+ self . errorMessage = " Failed to load repository : \( error. localizedDescription) "
8496 self . apps = [ ]
8597 }
8698 }
8799
100+ /// Public method to refresh on demand
88101 func refresh( ) {
89102 Task { await load ( ) }
90103 }
91104}
92105
93- // MARK: - AppsView (no NavigationView)
106+ // MARK: - The SwiftUI View (no NavigationView wrapper )
94107public struct AppsView : View {
95108 @StateObject private var vm : RepoViewModel
96109
97- /// Provide custom repo URL if you want (defaults to https://repository.apptesters.org/ )
110+ // Public initializer so you can pass a custom repository URL (defaults to user's provided URL )
98111 public init ( repoURL: URL = URL ( string: " https://repository.apptesters.org/ " ) !) {
99112 _vm = StateObject ( wrappedValue: RepoViewModel ( sourceURL: repoURL) )
100113 }
@@ -123,20 +136,21 @@ public struct AppsView: View {
123136 . padding ( )
124137 } else {
125138 List ( vm. apps) { app in
126- // parent NavigationStack handles navigation; provide a NavigationLink to a detail view
127- NavigationLink ( value: app) {
128- AppRowView ( app: app)
129- }
139+ AppRowView ( app: app)
130140 }
131- . listStyle ( . plain)
132- . refreshable { vm. refresh ( ) } // iOS 15+
133- // If you don't want navigation links, swap NavigationLink -> Button/openURL as you prefer.
134- . navigationDestination ( for: AltApp . self) { app in
135- AppDetailView ( app: app)
141+ . listStyle ( PlainListStyle ( ) )
142+ . refreshable { vm. refresh ( ) }
143+ }
144+ }
145+ . toolbar {
146+ ToolbarItem ( placement: . navigationBarTrailing) {
147+ Button ( action: { vm. refresh ( ) } ) {
148+ Image ( systemName: " arrow.clockwise " )
136149 }
150+ . help ( " Refresh repository " )
137151 }
138152 }
139- // Do not set navigationTitle here — your parent already does that
153+ . padding ( . horizontal , 0 )
140154 }
141155}
142156
@@ -153,10 +167,11 @@ private struct AppRowView: View {
153167 ProgressView ( )
154168 . frame ( width: 48 , height: 48 )
155169 case . success( let image) :
156- image. resizable ( )
170+ image
171+ . resizable ( )
157172 . scaledToFill ( )
158173 . frame ( width: 48 , height: 48 )
159- . clipShape ( RoundedRectangle ( cornerRadius: 8 , style: . continuous) )
174+ . clipShape ( RoundedRectangle ( cornerRadius: 10 , style: . continuous) )
160175 . shadow ( radius: 1 , y: 1 )
161176 case . failure:
162177 Image ( systemName: " app " )
@@ -206,91 +221,4 @@ private struct AppRowView: View {
206221 }
207222 . padding ( . vertical, 6 )
208223 }
209- }
210-
211- // MARK: - Simple Detail View
212- private struct AppDetailView : View {
213- let app : AltApp
214- @Environment ( \. openURL) private var openURL
215-
216- var body : some View {
217- ScrollView {
218- VStack ( spacing: 16 ) {
219- if let iconURL = app. iconURL {
220- AsyncImage ( url: iconURL) { phase in
221- switch phase {
222- case . empty:
223- ProgressView ( )
224- . frame ( width: 120 , height: 120 )
225- case . success( let image) :
226- image. resizable ( )
227- . scaledToFit ( )
228- . frame ( width: 120 , height: 120 )
229- . clipShape ( RoundedRectangle ( cornerRadius: 16 ) )
230- default :
231- Image ( systemName: " app " )
232- . resizable ( )
233- . scaledToFit ( )
234- . frame ( width: 100 , height: 100 )
235- }
236- }
237- }
238-
239- VStack ( alignment: . leading, spacing: 6 ) {
240- Text ( app. name)
241- . font ( . title2)
242- . bold ( )
243- if let dev = app. developerName {
244- Text ( dev)
245- . font ( . subheadline)
246- . foregroundColor ( . secondary)
247- }
248- if let desc = app. localizedDescription {
249- Text ( desc)
250- . font ( . body)
251- . padding ( . top, 6 )
252- }
253- }
254- . frame ( maxWidth: . infinity, alignment: . leading)
255- . padding ( . horizontal)
256-
257- if let version = app. versions? . first {
258- VStack ( alignment: . leading, spacing: 6 ) {
259- Text ( " Latest " )
260- . font ( . headline)
261- HStack {
262- VStack ( alignment: . leading) {
263- if let v = version. version { Text ( " Version: \( v) " ) }
264- if let b = version. buildVersion { Text ( " Build: \( b) " ) }
265- if let min = version. minOSVersion { Text ( " Min iOS: \( min) " ) }
266- if let size = version. size {
267- Text ( " Size: \( ByteCountFormatter . string ( fromByteCount: Int64 ( size) , countStyle: . file) ) " )
268- }
269- }
270- Spacer ( )
271- }
272- }
273- . padding ( )
274- . background ( . thinMaterial)
275- . clipShape ( RoundedRectangle ( cornerRadius: 12 ) )
276- . padding ( . horizontal)
277- }
278-
279- if let url = app. latestDownloadURL {
280- Button ( action: { openURL ( url) } ) {
281- Label ( " Open download URL " , systemImage: " arrow.down.doc " )
282- . frame ( maxWidth: . infinity)
283- }
284- . buttonStyle ( . borderedProminent)
285- . padding ( . horizontal)
286- }
287-
288- Spacer ( minLength: 20 )
289- }
290- . padding ( . top)
291- }
292- . navigationTitle ( app. name)
293- . navigationBarTitleDisplayMode ( . inline)
294- }
295-
296- }
224+ }
0 commit comments