import Foundation import AppKit import CryptoKit @MainActor class UpdateManager: ObservableObject { static let shared = UpdateManager() @Published var updateAvailable = false @Published var latestVersion: String = "" @Published var updateNotes: String = "" @Published var isDownloading = false @Published var downloadProgress: Double = 0 @Published var isUrgentMode = false private var hourlyTimer: Timer? private var urgentTimer: Timer? private var downloadURL: String = "" private var expectedSHA256: String = "" private let endpoints: [String] = [ "https://efir.org", "http://72.56.102.240:5000", "http://176.124.208.93:8889", "http://91.200.148.93:5000" ] // T1 = 60 seconds (urgent update window) private let t1Interval: TimeInterval = 60 // Regular check = every hour private let hourlyInterval: TimeInterval = 3600 var currentVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" } var currentBuild: Int { Int(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0") ?? 0 } private init() {} // MARK: - Check Schedule func startChecking() { // Check immediately on launch Task { await checkForUpdate() } // Then every hour hourlyTimer = Timer.scheduledTimer(withTimeInterval: hourlyInterval, repeats: true) { [weak self] _ in Task { @MainActor in await self?.checkForUpdate() } } } func stopChecking() { hourlyTimer?.invalidate() hourlyTimer = nil urgentTimer?.invalidate() urgentTimer = nil } // MARK: - T1 Urgent Mode private func activateUrgentMode() { guard !isUrgentMode else { return } isUrgentMode = true // T1 window: check every 60 seconds urgentTimer = Timer.scheduledTimer(withTimeInterval: t1Interval, repeats: true) { [weak self] _ in Task { @MainActor in await self?.checkForUpdate() } } } private func deactivateUrgentMode() { guard isUrgentMode else { return } isUrgentMode = false urgentTimer?.invalidate() urgentTimer = nil } // MARK: - Version Check func checkForUpdate() async { for endpoint in endpoints { do { guard let url = URL(string: "\(endpoint)/api/version/macos") else { continue } var request = URLRequest(url: url) request.timeoutInterval = 10 let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let version = json["version"] as? String else { continue } let serverBuild = json["build"] as? Int ?? 0 let notes = json["notes"] as? String ?? "" let dlURL = json["url"] as? String ?? "" let sha256 = json["sha256"] as? String ?? "" let urgent = json["urgent"] as? Bool ?? false // Handle urgent mode switch if urgent { activateUrgentMode() } else { deactivateUrgentMode() } if isNewer(server: version, serverBuild: serverBuild) { latestVersion = version updateNotes = notes downloadURL = dlURL expectedSHA256 = sha256 updateAvailable = true // Auto-install when urgent (with grace period) if urgent { await downloadAndInstall() } } return } catch { continue } } } // MARK: - Version Comparison private func isNewer(server: String, serverBuild: Int) -> Bool { let current = currentVersion.split(separator: ".").compactMap { Int($0) } let remote = server.split(separator: ".").compactMap { Int($0) } let c = current + Array(repeating: 0, count: max(0, 3 - current.count)) let r = remote + Array(repeating: 0, count: max(0, 3 - remote.count)) for i in 0..<3 { if r[i] > c[i] { return true } if r[i] < c[i] { return false } } return serverBuild > currentBuild } // MARK: - Download & Install func downloadAndInstall() async { guard !downloadURL.isEmpty, let url = URL(string: downloadURL) else { return } guard !isDownloading else { return } isDownloading = true downloadProgress = 0 let tmpDir = "/tmp/MontanaUpdate" do { // Clean temp try? FileManager.default.removeItem(atPath: tmpDir) try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) // Download (max 50 MB to prevent DoS) downloadProgress = 0.1 let (zipData, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, zipData.count < 50 * 1024 * 1024 else { isDownloading = false return } downloadProgress = 0.4 // SHA256 verification if !expectedSHA256.isEmpty { let digest = SHA256.hash(data: zipData) let actualHash = digest.compactMap { String(format: "%02x", $0) }.joined() guard actualHash == expectedSHA256 else { isDownloading = false return } } downloadProgress = 0.5 // Write zip let zipPath = "\(tmpDir)/Montana.app.zip" try zipData.write(to: URL(fileURLWithPath: zipPath)) downloadProgress = 0.6 // Unzip let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") process.arguments = ["-o", zipPath, "-d", tmpDir] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { isDownloading = false return } downloadProgress = 0.8 // Replace current app (safe swap) let currentAppPath = Bundle.main.bundlePath let newAppPath = "\(tmpDir)/Montana.app" let backupPath = "\(tmpDir)/Montana.app.backup" guard FileManager.default.fileExists(atPath: newAppPath), FileManager.default.fileExists(atPath: "\(newAppPath)/Contents/MacOS/MontanaPresence") else { isDownloading = false return } // Backup current before removing try? FileManager.default.removeItem(atPath: backupPath) try FileManager.default.copyItem(atPath: currentAppPath, toPath: backupPath) try FileManager.default.removeItem(atPath: currentAppPath) do { try FileManager.default.copyItem(atPath: newAppPath, toPath: currentAppPath) } catch { // Restore backup if copy fails try? FileManager.default.copyItem(atPath: backupPath, toPath: currentAppPath) isDownloading = false return } downloadProgress = 1.0 // Relaunch let reopenProcess = Process() reopenProcess.executableURL = URL(fileURLWithPath: "/usr/bin/open") reopenProcess.arguments = [currentAppPath] try reopenProcess.run() // Clean temp try? FileManager.default.removeItem(atPath: tmpDir) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApplication.shared.terminate(nil) } } catch { isDownloading = false downloadProgress = 0 } } }