1+ import Foundation
2+
3+ /// Generates Sparkle-compatible appcast XML from GitHub Releases API data
4+ struct AppcastGenerator {
5+
6+ // MARK: - Data Models
7+
8+ struct GitHubRelease : Codable {
9+ let id : Int
10+ let tagName : String
11+ let name : String ?
12+ let body : String ?
13+ let prerelease : Bool
14+ let draft : Bool
15+ let publishedAt : String ?
16+ let assets : [ GitHubAsset ]
17+ let htmlUrl : String
18+
19+ private enum CodingKeys : String , CodingKey {
20+ case id, name, body, prerelease, draft, assets
21+ case tagName = " tag_name "
22+ case publishedAt = " published_at "
23+ case htmlUrl = " html_url "
24+ }
25+ }
26+
27+ struct GitHubAsset : Codable {
28+ let id : Int
29+ let name : String
30+ let size : Int
31+ let downloadCount : Int
32+ let browserDownloadUrl : String
33+
34+ private enum CodingKeys : String , CodingKey {
35+ case id, name, size
36+ case downloadCount = " download_count "
37+ case browserDownloadUrl = " browser_download_url "
38+ }
39+ }
40+
41+ // MARK: - Configuration
42+
43+ struct AppcastConfig {
44+ let appName : String
45+ let bundleId : String
46+ let repository : String
47+ let minimumSystemVersion : String
48+ let includeBetaReleases : Bool
49+
50+ static let `default` = AppcastConfig (
51+ appName: " ClickIt " ,
52+ bundleId: AppConstants . appcastURL. contains ( " clickit " ) ? " com.jsonify.clickit " : " com.example.clickit " ,
53+ repository: AppConstants . githubRepository,
54+ minimumSystemVersion: AppConstants . minimumOSVersion,
55+ includeBetaReleases: false
56+ )
57+ }
58+
59+ // MARK: - Public Methods
60+
61+ /// Fetches GitHub releases and generates appcast XML
62+ static func generateAppcast( config: AppcastConfig = . default) async throws -> String {
63+ let releases = try await fetchGitHubReleases ( repository: config. repository)
64+ let filteredReleases = filterReleases ( releases, includeBeta: config. includeBetaReleases)
65+ return generateAppcastXML ( releases: filteredReleases, config: config)
66+ }
67+
68+ /// Generates appcast XML from provided releases data
69+ static func generateAppcastXML( releases: [ GitHubRelease ] , config: AppcastConfig ) -> String {
70+ let items = releases. compactMap { release -> String ? in
71+ generateAppcastItem ( release: release, config: config)
72+ }
73+
74+ return generateFullAppcast ( items: items, config: config)
75+ }
76+
77+ // MARK: - Private Methods
78+
79+ /// Fetches releases from GitHub API
80+ private static func fetchGitHubReleases( repository: String ) async throws -> [ GitHubRelease ] {
81+ guard let url = URL ( string: " https://api.github.com/repos/ \( repository) /releases " ) else {
82+ throw AppcastError . invalidURL
83+ }
84+
85+ let ( data, response) = try await URLSession . shared. data ( from: url)
86+
87+ guard let httpResponse = response as? HTTPURLResponse ,
88+ 200 ... 299 ~= httpResponse. statusCode else {
89+ throw AppcastError . networkError
90+ }
91+
92+ do {
93+ return try JSONDecoder ( ) . decode ( [ GitHubRelease ] . self, from: data)
94+ } catch {
95+ throw AppcastError . decodingError ( error)
96+ }
97+ }
98+
99+ /// Filters releases based on configuration
100+ private static func filterReleases( _ releases: [ GitHubRelease ] , includeBeta: Bool ) -> [ GitHubRelease ] {
101+ return releases. filter { release in
102+ // Skip drafts
103+ guard !release. draft else { return false }
104+
105+ // Include/exclude prerelease based on configuration
106+ if release. prerelease && !includeBeta {
107+ return false
108+ }
109+
110+ // Must have at least one asset
111+ return !release. assets. isEmpty
112+ }
113+ . sorted { lhs, rhs in
114+ // Sort by published date, newest first
115+ guard let lhsDate = parseDate ( lhs. publishedAt) ,
116+ let rhsDate = parseDate ( rhs. publishedAt) else {
117+ return false
118+ }
119+ return lhsDate > rhsDate
120+ }
121+ }
122+
123+ /// Generates individual appcast item XML
124+ private static func generateAppcastItem( release: GitHubRelease , config: AppcastConfig ) -> String ? {
125+ // Find the main app asset (typically .zip or .dmg)
126+ guard let mainAsset = findMainAsset ( in: release. assets) else {
127+ return nil
128+ }
129+
130+ let version = extractVersion ( from: release. tagName)
131+ let title = release. name ?? " \( config. appName) \( version) "
132+ let description = formatReleaseNotes ( release. body)
133+ let pubDate = formatPubDate ( release. publishedAt)
134+
135+ return """
136+ <item>
137+ <title><![CDATA[ \( title) ]]></title>
138+ <description><![CDATA[ \( description) ]]></description>
139+ <link> \( release. htmlUrl) </link>
140+ <sparkle:version> \( version) </sparkle:version>
141+ <sparkle:shortVersionString> \( version) </sparkle:shortVersionString>
142+ <sparkle:minimumSystemVersion> \( config. minimumSystemVersion) </sparkle:minimumSystemVersion>
143+ <pubDate> \( pubDate) </pubDate>
144+ <enclosure url= " \( mainAsset. browserDownloadUrl) "
145+ length= " \( mainAsset. size) "
146+ type= " application/octet-stream "
147+ sparkle:version= " \( version) "
148+ sparkle:shortVersionString= " \( version) " />
149+ </item>
150+ """
151+ }
152+
153+ /// Finds the main downloadable asset (ZIP or DMG)
154+ private static func findMainAsset( in assets: [ GitHubAsset ] ) -> GitHubAsset ? {
155+ // Prefer ZIP files for auto-updates, then DMG
156+ return assets. first { asset in
157+ asset. name. lowercased ( ) . hasSuffix ( " .zip " )
158+ } ?? assets. first { asset in
159+ asset. name. lowercased ( ) . hasSuffix ( " .dmg " )
160+ }
161+ }
162+
163+ /// Extracts version number from Git tag
164+ private static func extractVersion( from tagName: String ) -> String {
165+ // Remove common prefixes like "v", "beta-v", etc.
166+ let cleanTag = tagName. replacingOccurrences ( of: " ^(beta-)?v? " , with: " " , options: . regularExpression)
167+ return cleanTag. isEmpty ? tagName : cleanTag
168+ }
169+
170+ /// Formats release notes for XML
171+ private static func formatReleaseNotes( _ body: String ? ) -> String {
172+ guard let body = body, !body. trimmingCharacters ( in: . whitespacesAndNewlines) . isEmpty else {
173+ return " No release notes available. "
174+ }
175+
176+ // Basic HTML conversion for better display
177+ let formatted = body
178+ . replacingOccurrences ( of: " ### " , with: " <h3> " )
179+ . replacingOccurrences ( of: " ## " , with: " <h2> " )
180+ . replacingOccurrences ( of: " # " , with: " <h1> " )
181+ . replacingOccurrences ( of: " \n " , with: " <br> " )
182+
183+ return formatted
184+ }
185+
186+ /// Formats publication date for RSS
187+ private static func formatPubDate( _ publishedAt: String ? ) -> String {
188+ guard let publishedAt = publishedAt,
189+ let date = parseDate ( publishedAt) else {
190+ return RFC822DateFormatter . string ( from: Date ( ) )
191+ }
192+
193+ return RFC822DateFormatter . string ( from: date)
194+ }
195+
196+ /// Parses ISO 8601 date string
197+ private static func parseDate( _ dateString: String ? ) -> Date ? {
198+ guard let dateString = dateString else { return nil }
199+
200+ let formatter = ISO8601DateFormatter ( )
201+ formatter. formatOptions = [ . withInternetDateTime, . withFractionalSeconds]
202+
203+ return formatter. date ( from: dateString) ?? ISO8601DateFormatter ( ) . date ( from: dateString)
204+ }
205+
206+ /// Generates complete appcast XML structure
207+ private static func generateFullAppcast( items: [ String ] , config: AppcastConfig ) -> String {
208+ let itemsXML = items. joined ( separator: " \n " )
209+ let lastBuildDate = RFC822DateFormatter . string ( from: Date ( ) )
210+
211+ return """
212+ <?xml version= " 1.0 " encoding= " UTF-8 " ?>
213+ <rss version= " 2.0 " xmlns:sparkle= " http://www.andymatuschak.org/xml-namespaces/sparkle " xmlns:dc= " http://purl.org/dc/elements/1.1/ " >
214+ <channel>
215+ <title> \( config. appName) Updates</title>
216+ <link>https://github.com/ \( config. repository) </link>
217+ <description>Software updates for \( config. appName) </description>
218+ <language>en</language>
219+ <lastBuildDate> \( lastBuildDate) </lastBuildDate>
220+
221+ \( itemsXML)
222+
223+ </channel>
224+ </rss>
225+ """
226+ }
227+
228+ // MARK: - Date Formatter
229+
230+ private static let RFC822DateFormatter : DateFormatter = {
231+ let formatter = DateFormatter ( )
232+ formatter. dateFormat = " EEE, dd MMM yyyy HH:mm:ss Z "
233+ formatter. locale = Locale ( identifier: " en_US_POSIX " )
234+ formatter. timeZone = TimeZone ( abbreviation: " GMT " )
235+ return formatter
236+ } ( )
237+ }
238+
239+ // MARK: - Error Types
240+
241+ enum AppcastError : Error , LocalizedError {
242+ case invalidURL
243+ case networkError
244+ case decodingError( Error )
245+ case noValidReleases
246+
247+ var errorDescription : String ? {
248+ switch self {
249+ case . invalidURL:
250+ return " Invalid GitHub API URL "
251+ case . networkError:
252+ return " Network request failed "
253+ case . decodingError( let error) :
254+ return " Failed to decode GitHub API response: \( error. localizedDescription) "
255+ case . noValidReleases:
256+ return " No valid releases found "
257+ }
258+ }
259+ }
260+
261+ // MARK: - Convenience Extensions
262+
263+ extension AppcastGenerator {
264+
265+ /// Generates appcast for beta releases
266+ static func generateBetaAppcast( ) async throws -> String {
267+ var config = AppcastConfig . default
268+ config = AppcastConfig (
269+ appName: config. appName,
270+ bundleId: config. bundleId,
271+ repository: config. repository,
272+ minimumSystemVersion: config. minimumSystemVersion,
273+ includeBetaReleases: true
274+ )
275+ return try await generateAppcast ( config: config)
276+ }
277+
278+ /// Validates that the generated XML is well-formed
279+ static func validateAppcastXML( _ xml: String ) -> Bool {
280+ guard let data = xml. data ( using: . utf8) else { return false }
281+
282+ let parser = XMLParser ( data: data)
283+ let delegate = XMLValidationDelegate ( )
284+ parser. delegate = delegate
285+
286+ return parser. parse ( ) && !delegate. hasError
287+ }
288+ }
289+
290+ // MARK: - XML Validation
291+
292+ private class XMLValidationDelegate : NSObject , XMLParserDelegate {
293+ var hasError = false
294+
295+ func parser( _ parser: XMLParser , parseErrorOccurred parseError: Error ) {
296+ hasError = true
297+ }
298+
299+ func parser( _ parser: XMLParser , validationErrorOccurred validationError: Error ) {
300+ hasError = true
301+ }
302+ }
0 commit comments