feat: auto-update feature

This commit is contained in:
Michael Freno
2026-01-11 17:45:32 -05:00
parent 37564d0579
commit 0f9ee7a58a
15 changed files with 950 additions and 98 deletions

View File

@@ -13,6 +13,7 @@ import Combine
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@Published var timerEngine: TimerEngine?
private var settingsManager: SettingsManager?
private var updateManager: UpdateManager?
private var reminderWindowController: NSWindowController?
private var settingsWindowController: NSWindowController?
private var cancellables = Set<AnyCancellable>()
@@ -26,6 +27,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
settingsManager = SettingsManager.shared
timerEngine = TimerEngine(settingsManager: settingsManager!)
// Initialize update manager after onboarding is complete
if settingsManager!.settings.hasCompletedOnboarding {
updateManager = UpdateManager.shared
}
// Detect App Store version asynchronously at launch
Task {
await settingsManager?.detectAppStoreVersion()
@@ -42,6 +48,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func onboardingCompleted() {
startTimers()
// Start update checks after onboarding
if updateManager == nil {
updateManager = UpdateManager.shared
}
}
private func startTimers() {

15
Gaze/Gaze.entitlements Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict>
</plist>

View File

@@ -16,5 +16,15 @@
<string>$(MARKETING_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Mike Freno. All rights reserved.</string>
<key>SUPublicEDKey</key>
<string>Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=</string>
<key>SUFeedURL</key>
<string>https://freno.me/api/Gaze/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<true/>
</dict>
</plist>

View File

@@ -1,68 +0,0 @@
//
// AnimationService.swift
// Gaze
//
// Created by Mike Freno on 1/9/26.
//
import Foundation
@MainActor
class AnimationService {
static let shared = AnimationService()
private init() {}
struct RemoteAnimation: Codable {
let name: String
let version: String
let date: String // ISO 8601 formatted date string
enum CodingKeys: String, CodingKey {
case name, version, date
}
}
struct RemoteAnimationsResponse: Codable {
let animations: [RemoteAnimation]
}
// MARK: - Public Methods
func fetchRemoteAnimations() async throws -> [RemoteAnimation] {
guard let url = URL(string: "https://freno.me/api/Gaze/animations") else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200...299 ~= httpResponse.statusCode else {
throw URLError(.badServerResponse)
}
do {
let decoder = JSONDecoder()
let remoteAnimations = try decoder.decode(RemoteAnimationsResponse.self, from: data)
return remoteAnimations.animations
} catch {
throw error
}
}
func updateLocalAnimationsIfNeeded(remoteAnimations: [RemoteAnimation]) async throws {
// For now, just validate the API response structure.
// In a real implementation, this would:
// 1. Compare dates of local vs remote animations
// 2. Update local files if newer versions exist
// 3. Tag local files with date fields in ISO 8601 format
for animation in remoteAnimations {
print("Remote animation: \(animation.name) - \(animation.version) - \(animation.date)")
}
}
}

View File

@@ -0,0 +1,73 @@
//
// UpdateManager.swift
// Gaze
//
// Created by Mike Freno on 1/11/26.
//
import Combine
import Foundation
import Sparkle
@MainActor
class UpdateManager: NSObject, ObservableObject {
static let shared = UpdateManager()
private var updaterController: SPUStandardUpdaterController?
private var automaticallyChecksObservation: NSKeyValueObservation?
private var lastCheckDateObservation: NSKeyValueObservation?
@Published var automaticallyChecksForUpdates = false
@Published var lastUpdateCheckDate: Date?
private override init() {
super.init()
setupUpdater()
}
private func setupUpdater() {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
guard let updater = updaterController?.updater else {
print("Failed to initialize Sparkle updater")
return
}
automaticallyChecksObservation = updater.observe(
\.automaticallyChecksForUpdates,
options: [.new, .initial]
) { [weak self] _, change in
guard let self = self, let newValue = change.newValue else { return }
Task { @MainActor in
self.automaticallyChecksForUpdates = newValue
}
}
lastCheckDateObservation = updater.observe(
\.lastUpdateCheckDate,
options: [.new, .initial]
) { [weak self] _, change in
guard let self = self else { return }
Task { @MainActor in
self.lastUpdateCheckDate = change.newValue ?? nil
}
}
}
func checkForUpdates() {
guard let updater = updaterController?.updater else {
print("Updater not initialized")
return
}
updater.checkForUpdates()
}
deinit {
automaticallyChecksObservation?.invalidate()
lastCheckDateObservation?.invalidate()
}
}

View File

@@ -11,6 +11,7 @@ struct GeneralSetupView: View {
@Binding var launchAtLogin: Bool
@Binding var subtleReminderSize: ReminderSize
@Binding var isAppStoreVersion: Bool
@ObservedObject var updateManager = UpdateManager.shared
var isOnboarding: Bool = true
var body: some View {
@@ -52,6 +53,34 @@ struct GeneralSetupView: View {
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
// Software Updates Section
if !isAppStoreVersion {
VStack(alignment: .leading, spacing: 12) {
Text("Software Updates")
.font(.headline)
Toggle("Automatically check for updates", isOn: $updateManager.automaticallyChecksForUpdates)
.help("Check for new versions of Gaze in the background")
if let lastCheck = updateManager.lastUpdateCheckDate {
Text("Last checked: \(lastCheck, style: .relative)")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Never checked for updates")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Check for Updates Now") {
updateManager.checkForUpdates()
}
.buttonStyle(.bordered)
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size")
.font(.headline)