Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions freewrite/UpdateChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// UpdateChecker.swift
// freewrite
//
// Checks GitHub Releases API for newer versions on app launch.
//

import SwiftUI

@Observable
final class UpdateChecker {

var updateAvailable = false
var latestVersion = ""
var releaseURL: URL?

private static let endpoint = "https://api.github.com/repos/farzaa/freewrite/releases/latest"
private static let cooldown: TimeInterval = 3600 // 1 hour
private static let lastCheckKey = "UpdateChecker.lastCheckTime"
private static let dismissedKey = "UpdateChecker.dismissedVersion"

private struct GitHubRelease: Codable {
let tagName: String
let htmlUrl: String

enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlUrl = "html_url"
}
}

func checkForUpdate() async {
let now = Date().timeIntervalSince1970
let lastCheck = UserDefaults.standard.double(forKey: Self.lastCheckKey)
if now - lastCheck < Self.cooldown { return }
UserDefaults.standard.set(now, forKey: Self.lastCheckKey)

do {
guard let url = URL(string: Self.endpoint) else { return }
var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 10

let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }

let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let remote = release.tagName.hasPrefix("v")
? String(release.tagName.dropFirst())
: release.tagName

let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"

guard isNewer(remote, than: current) else { return }

let dismissed = UserDefaults.standard.string(forKey: Self.dismissedKey)
if dismissed == remote { return }

await MainActor.run {
latestVersion = remote
releaseURL = URL(string: release.htmlUrl)
updateAvailable = true
}
} catch {
// Silent failure — never bother the user
}
}

func openReleasePage() {
if let url = releaseURL {
NSWorkspace.shared.open(url)
}
updateAvailable = false
}

func dismissUpdate() {
UserDefaults.standard.set(latestVersion, forKey: Self.dismissedKey)
updateAvailable = false
}

private func isNewer(_ remote: String, than current: String) -> Bool {
let r = remote.split(separator: ".").compactMap { Int($0) }
let c = current.split(separator: ".").compactMap { Int($0) }

// If parsing failed for either, don't show update
guard r.count == remote.split(separator: ".").count,
c.count == current.split(separator: ".").count else { return false }

let maxLen = max(r.count, c.count)
for i in 0..<maxLen {
let rv = i < r.count ? r[i] : 0
let cv = i < c.count ? c[i] : 0
if rv > cv { return true }
if rv < cv { return false }
}
return false
}
}
2 changes: 2 additions & 0 deletions freewrite/freewrite.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@
<true/>
<key>com.apple.security.personal-information.speech-recognition</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
11 changes: 10 additions & 1 deletion freewrite/freewriteApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import SwiftUI
struct freewriteApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@AppStorage("colorScheme") private var colorSchemeString: String = "light"

@State private var updateChecker = UpdateChecker()

init() {
// Register Lato font
if let fontURL = Bundle.main.url(forResource: "Lato-Regular", withExtension: "ttf") {
Expand All @@ -24,6 +25,14 @@ struct freewriteApp: App {
ContentView()
.toolbar(.hidden, for: .windowToolbar)
.preferredColorScheme(colorSchemeString == "dark" ? .dark : .light)
.task { await updateChecker.checkForUpdate() }
.alert("Update Available", isPresented: $updateChecker.updateAvailable) {
Button("Download Update") { updateChecker.openReleasePage() }
Button("Later", role: .cancel) { updateChecker.dismissUpdate() }
} message: {
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
Text("Freewrite \(updateChecker.latestVersion) is available. You're currently on \(current).")
}
}
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1100, height: 600)
Expand Down