feat: auto-update feature
This commit is contained in:
@@ -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
15
Gaze/Gaze.entitlements
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
Gaze/Services/UpdateManager.swift
Normal file
73
Gaze/Services/UpdateManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user