Skip to content

Commit 42f043f

Browse files
authored
Merge pull request #1 from jsonify:feature/auto-update-system
Implement auto-update system with Sparkle framework
2 parents a8f9c16 + 0bf75d0 commit 42f043f

File tree

14 files changed

+2847
-3
lines changed

14 files changed

+2847
-3
lines changed

.github/workflows/cicd.yml

Lines changed: 531 additions & 1 deletion
Large diffs are not rendered by default.

Package.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ let package = Package(
1515
)
1616
],
1717
dependencies: [
18-
// No external dependencies required for now
18+
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.5.2")
1919
],
2020
targets: [
2121
.executableTarget(
2222
name: "ClickIt",
23-
dependencies: [],
23+
dependencies: [
24+
.product(name: "Sparkle", package: "Sparkle")
25+
],
2426
resources: [.process("Resources")]
2527
),
2628
.testTarget(
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)