mirror of
https://github.com/nicoverbruggen/phpmon.git
synced 2025-11-05 04:20:06 +01:00
♻️ Lint tests, add background update check
This commit is contained in:
@@ -3,13 +3,25 @@ disabled_rules:
|
|||||||
- identifier_name
|
- identifier_name
|
||||||
- force_try
|
- force_try
|
||||||
- force_cast
|
- force_cast
|
||||||
|
- private_over_fileprivate
|
||||||
|
|
||||||
opt_in_rules:
|
opt_in_rules:
|
||||||
- empty_count
|
- empty_count
|
||||||
|
|
||||||
included:
|
included:
|
||||||
- phpmon
|
- phpmon
|
||||||
- phpmon-tests
|
- phpmon-updater
|
||||||
|
- tests
|
||||||
|
|
||||||
excluded:
|
excluded:
|
||||||
- phpmon/Vendor
|
- phpmon/Vendor
|
||||||
|
|
||||||
|
line_length:
|
||||||
|
ignores_function_declarations: true
|
||||||
|
ignores_comments: true
|
||||||
|
ignores_urls: true
|
||||||
|
warning: 120
|
||||||
|
error: 200
|
||||||
|
|
||||||
|
analyzer_rules:
|
||||||
|
- unused_import
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
031E2B6A2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */; };
|
031E2B6A2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */; };
|
||||||
031E2B6B2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */; };
|
031E2B6B2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */; };
|
||||||
031E2B6C2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */; };
|
031E2B6C2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */; };
|
||||||
|
03263A382E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03263A372E86D5E800BD0415 /* UpdateScheduler.swift */; };
|
||||||
|
03263A392E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03263A372E86D5E800BD0415 /* UpdateScheduler.swift */; };
|
||||||
|
03263A3A2E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03263A372E86D5E800BD0415 /* UpdateScheduler.swift */; };
|
||||||
|
03263A3B2E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03263A372E86D5E800BD0415 /* UpdateScheduler.swift */; };
|
||||||
033D45982B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */; };
|
033D45982B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */; };
|
||||||
033D45992B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */; };
|
033D45992B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */; };
|
||||||
033D459A2B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */; };
|
033D459A2B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */; };
|
||||||
@@ -957,6 +961,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0309E6662B0D4B2F002AC007 /* BrewExtensionsObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewExtensionsObservable.swift; sourceTree = "<group>"; };
|
0309E6662B0D4B2F002AC007 /* BrewExtensionsObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewExtensionsObservable.swift; sourceTree = "<group>"; };
|
||||||
031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPhpExtension.swift; sourceTree = "<group>"; };
|
031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPhpExtension.swift; sourceTree = "<group>"; };
|
||||||
|
03263A372E86D5E800BD0415 /* UpdateScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateScheduler.swift; sourceTree = "<group>"; };
|
||||||
0336CAAF2B0D0CDA009A1034 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
0336CAAF2B0D0CDA009A1034 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallPhpExtensionCommand.swift; sourceTree = "<group>"; };
|
033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallPhpExtensionCommand.swift; sourceTree = "<group>"; };
|
||||||
033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemovePhpExtensionCommand.swift; sourceTree = "<group>"; };
|
033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemovePhpExtensionCommand.swift; sourceTree = "<group>"; };
|
||||||
@@ -2058,6 +2063,7 @@
|
|||||||
C495F5AE28A42E080087F70A /* EnvironmentCheck.swift */,
|
C495F5AE28A42E080087F70A /* EnvironmentCheck.swift */,
|
||||||
C40FE736282ABA4F00A302C2 /* AppVersion.swift */,
|
C40FE736282ABA4F00A302C2 /* AppVersion.swift */,
|
||||||
C409349C298EE8E900D25014 /* AppUpdater.swift */,
|
C409349C298EE8E900D25014 /* AppUpdater.swift */,
|
||||||
|
03263A372E86D5E800BD0415 /* UpdateScheduler.swift */,
|
||||||
);
|
);
|
||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -2679,6 +2685,7 @@
|
|||||||
C48DDD0D29C75C9E00D032D9 /* BlockingOverlayView.swift in Sources */,
|
C48DDD0D29C75C9E00D032D9 /* BlockingOverlayView.swift in Sources */,
|
||||||
C45B91532956123A00F4EC78 /* FakeServicesManager.swift in Sources */,
|
C45B91532956123A00F4EC78 /* FakeServicesManager.swift in Sources */,
|
||||||
C41C708D28AA7F7900E8D498 /* NoWarningsView.swift in Sources */,
|
C41C708D28AA7F7900E8D498 /* NoWarningsView.swift in Sources */,
|
||||||
|
03263A382E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */,
|
||||||
0309E6672B0D4B2F002AC007 /* BrewExtensionsObservable.swift in Sources */,
|
0309E6672B0D4B2F002AC007 /* BrewExtensionsObservable.swift in Sources */,
|
||||||
C4E0F7ED27BEBDA9007475F2 /* NSWindowExtension.swift in Sources */,
|
C4E0F7ED27BEBDA9007475F2 /* NSWindowExtension.swift in Sources */,
|
||||||
C4205A7E27F4D21800191A39 /* ValetProxy.swift in Sources */,
|
C4205A7E27F4D21800191A39 /* ValetProxy.swift in Sources */,
|
||||||
@@ -3006,6 +3013,7 @@
|
|||||||
C471E81A28F9BAE80021E251 /* TimeIntervalExtension.swift in Sources */,
|
C471E81A28F9BAE80021E251 /* TimeIntervalExtension.swift in Sources */,
|
||||||
C471E7E128F9BAAB0021E251 /* RealCommand.swift in Sources */,
|
C471E7E128F9BAAB0021E251 /* RealCommand.swift in Sources */,
|
||||||
C471E7E228F9BAAB0021E251 /* ActiveCommand.swift in Sources */,
|
C471E7E228F9BAAB0021E251 /* ActiveCommand.swift in Sources */,
|
||||||
|
03263A392E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */,
|
||||||
C471E80A28F9BADC0021E251 /* CreatedFromFile.swift in Sources */,
|
C471E80A28F9BADC0021E251 /* CreatedFromFile.swift in Sources */,
|
||||||
C471E80528F9BAD40021E251 /* ActivePhpInstallation.swift in Sources */,
|
C471E80528F9BAD40021E251 /* ActivePhpInstallation.swift in Sources */,
|
||||||
C471E80628F9BAD40021E251 /* PhpInstallation.swift in Sources */,
|
C471E80628F9BAD40021E251 /* PhpInstallation.swift in Sources */,
|
||||||
@@ -3286,6 +3294,7 @@
|
|||||||
C471E80F28F9BAE80021E251 /* NSMenuExtension.swift in Sources */,
|
C471E80F28F9BAE80021E251 /* NSMenuExtension.swift in Sources */,
|
||||||
C471E80B28F9BAE80021E251 /* XibLoadable.swift in Sources */,
|
C471E80B28F9BAE80021E251 /* XibLoadable.swift in Sources */,
|
||||||
C471E7F428F9BAC80021E251 /* VersionNumber.swift in Sources */,
|
C471E7F428F9BAC80021E251 /* VersionNumber.swift in Sources */,
|
||||||
|
03263A3B2E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */,
|
||||||
C471E7CB28F9BA5B0021E251 /* TestableCommand.swift in Sources */,
|
C471E7CB28F9BA5B0021E251 /* TestableCommand.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -3344,6 +3353,7 @@
|
|||||||
54D9E0B527E4F51E003B9AD9 /* Key.swift in Sources */,
|
54D9E0B527E4F51E003B9AD9 /* Key.swift in Sources */,
|
||||||
C4AF9F7B2754499000D44ED0 /* Valet.swift in Sources */,
|
C4AF9F7B2754499000D44ED0 /* Valet.swift in Sources */,
|
||||||
C4C1019C27C65C6F001FACC2 /* Process.swift in Sources */,
|
C4C1019C27C65C6F001FACC2 /* Process.swift in Sources */,
|
||||||
|
03263A3A2E86D5EC00BD0415 /* UpdateScheduler.swift in Sources */,
|
||||||
C451AFF72969E40F0078E617 /* HelpButton.swift in Sources */,
|
C451AFF72969E40F0078E617 /* HelpButton.swift in Sources */,
|
||||||
C47DF1B0299D5A3B0007055D /* LoginItemManager.swift in Sources */,
|
C47DF1B0299D5A3B0007055D /* LoginItemManager.swift in Sources */,
|
||||||
C4F780C025D80B6E000DBC97 /* Startup.swift in Sources */,
|
C4F780C025D80B6E000DBC97 /* Startup.swift in Sources */,
|
||||||
|
|||||||
@@ -31,14 +31,25 @@ struct Constants {
|
|||||||
/**
|
/**
|
||||||
The interval between automatic background update checks.
|
The interval between automatic background update checks.
|
||||||
*/
|
*/
|
||||||
static let AutomaticUpdateCheckInterval: TimeInterval = 60 // 60.0 * 60 * 24 // 24 hours
|
static let AutomaticUpdateCheckInterval: TimeInterval = 15.0 // 60.0 * 60 * 24 // 24 hours
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The minimum interval that must pass before allowing another
|
The minimum interval that must pass before allowing another
|
||||||
automatic update check. This prevents excessive checking
|
automatic update check. This prevents excessive checking
|
||||||
on frequent app restarts (due to crashes or bad config).
|
on frequent app restarts (due to crashes or bad config).
|
||||||
*/
|
*/
|
||||||
static let MinimumUpdateCheckInterval: TimeInterval = 60 // 60.0 * 60 // 60 minutes
|
static let MinimumUpdateCheckInterval: TimeInterval = 5.0 // 60.0 * 60 // 60 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
Retry intervals for failed automatic update checks.
|
||||||
|
Uses exponential backoff: 5 min → 15 min → 1 hr → 3 hrs before falling back to normal schedule.
|
||||||
|
*/
|
||||||
|
static let UpdateCheckRetryIntervals: [TimeInterval] = [
|
||||||
|
300, // 5 minutes
|
||||||
|
900, // 15 minutes
|
||||||
|
3600, // 1 hour
|
||||||
|
10800 // 3 hours (final attempt)
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
PHP Monitor supplies a hardcoded list of PHP packages in its own
|
PHP Monitor supplies a hardcoded list of PHP packages in its own
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ import Foundation
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import NVAlert
|
import NVAlert
|
||||||
|
|
||||||
|
enum UpdateCheckResult {
|
||||||
|
case success
|
||||||
|
case networkError
|
||||||
|
case parseError
|
||||||
|
case disabled
|
||||||
|
}
|
||||||
|
|
||||||
class AppUpdater {
|
class AppUpdater {
|
||||||
var caskFile: CaskFile!
|
var caskFile: CaskFile!
|
||||||
var latestVersionOnline: AppVersion!
|
var latestVersionOnline: AppVersion!
|
||||||
var interactive: Bool = false
|
var interactive: Bool = false
|
||||||
|
|
||||||
public func checkForUpdates(userInitiated: Bool) async {
|
public func checkForUpdates(userInitiated: Bool) async -> UpdateCheckResult {
|
||||||
self.interactive = userInitiated
|
self.interactive = userInitiated
|
||||||
|
|
||||||
if !interactive && !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
|
if !interactive && !Preferences.isEnabled(.automaticBackgroundUpdateCheck) {
|
||||||
Log.info("Skipping automatic update check due to user preference.")
|
Log.info("Skipping automatic update check due to user preference.")
|
||||||
return
|
return .disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.info("The app will search for updates...")
|
Log.info("The app will search for updates...")
|
||||||
@@ -29,7 +36,8 @@ class AppUpdater {
|
|||||||
|
|
||||||
guard let caskFile = await CaskFile.from(url: caskUrl) else {
|
guard let caskFile = await CaskFile.from(url: caskUrl) else {
|
||||||
Log.err("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.")
|
Log.err("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.")
|
||||||
return presentCouldNotRetrieveUpdateIfInteractive()
|
presentCouldNotRetrieveUpdateIfInteractive()
|
||||||
|
return .networkError
|
||||||
}
|
}
|
||||||
|
|
||||||
self.caskFile = caskFile
|
self.caskFile = caskFile
|
||||||
@@ -38,7 +46,8 @@ class AppUpdater {
|
|||||||
|
|
||||||
guard let onlineVersion = AppVersion.from(caskFile.version) else {
|
guard let onlineVersion = AppVersion.from(caskFile.version) else {
|
||||||
Log.err("The version string from the CaskFile could not be read.")
|
Log.err("The version string from the CaskFile could not be read.")
|
||||||
return presentCouldNotRetrieveUpdateIfInteractive()
|
presentCouldNotRetrieveUpdateIfInteractive()
|
||||||
|
return .parseError
|
||||||
}
|
}
|
||||||
|
|
||||||
latestVersionOnline = onlineVersion
|
latestVersionOnline = onlineVersion
|
||||||
@@ -49,6 +58,8 @@ class AppUpdater {
|
|||||||
} else if interactive {
|
} else if interactive {
|
||||||
presentNoNewerVersionAvailableAlert()
|
presentNoNewerVersionAvailableAlert()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentCouldNotRetrieveUpdateIfInteractive() {
|
private func presentCouldNotRetrieveUpdateIfInteractive() {
|
||||||
|
|||||||
113
phpmon/Domain/App/UpdateScheduler.swift
Normal file
113
phpmon/Domain/App/UpdateScheduler.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//
|
||||||
|
// UpdateScheduler.swift
|
||||||
|
// PHP Monitor
|
||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 26/09/2025.
|
||||||
|
// Copyright © 2025 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class UpdateScheduler {
|
||||||
|
static let shared = UpdateScheduler()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Start the automatic update checking process.
|
||||||
|
This should be called once during app startup.
|
||||||
|
*/
|
||||||
|
func startAutomaticUpdateChecking() async {
|
||||||
|
await performUpdateCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Perform an automatic update check and schedule the next one.
|
||||||
|
*/
|
||||||
|
private func performUpdateCheck() async {
|
||||||
|
guard isNotThrottled() else {
|
||||||
|
// If we are throttled, just schedule a regular check the regular time from now!
|
||||||
|
scheduleTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// This check will be aborted if the preference disallows it in AppUpdater
|
||||||
|
let result = await AppUpdater().checkForUpdates(userInitiated: false)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
// Reset failure count and record successful check
|
||||||
|
UserDefaults.standard.removeObject(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue)
|
||||||
|
scheduleTimer()
|
||||||
|
Log.info("Update check completed successfully. Next check scheduled in \(Constants.AutomaticUpdateCheckInterval) seconds.")
|
||||||
|
|
||||||
|
case .disabled:
|
||||||
|
// User disabled automatic checks, don't schedule another
|
||||||
|
Log.info("Automatic update checks are disabled. No further checks will be scheduled.")
|
||||||
|
|
||||||
|
case .networkError, .parseError:
|
||||||
|
// Handle failures with exponential backoff
|
||||||
|
handleFailure(result: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Handle update check failures with exponential backoff retry logic.
|
||||||
|
*/
|
||||||
|
private func handleFailure(result: UpdateCheckResult) {
|
||||||
|
let currentFailureCount = UserDefaults.standard.integer(
|
||||||
|
forKey: PersistentAppState.updateCheckFailureCount.rawValue
|
||||||
|
)
|
||||||
|
let newFailureCount = currentFailureCount + 1
|
||||||
|
|
||||||
|
UserDefaults.standard.set(newFailureCount, forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
|
||||||
|
let retryInterval: TimeInterval
|
||||||
|
if newFailureCount <= Constants.UpdateCheckRetryIntervals.count {
|
||||||
|
// Use exponential backoff
|
||||||
|
retryInterval = Constants.UpdateCheckRetryIntervals[newFailureCount - 1]
|
||||||
|
Log.info("Update check failed (\(result)). Retry attempt \(newFailureCount) scheduled in \(retryInterval) seconds.")
|
||||||
|
} else {
|
||||||
|
// Exceeded max retries, fall back to normal schedule and reset counter
|
||||||
|
retryInterval = Constants.AutomaticUpdateCheckInterval
|
||||||
|
UserDefaults.standard.removeObject(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
Log.info("Update check failed (\(result)). Max retries exceeded. Falling back to normal schedule in \(retryInterval) seconds.")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleTimer(after: retryInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Determine whether another automatic update check should occur based on the last check timestamp.
|
||||||
|
Returns true if a check should happen, false otherwise.
|
||||||
|
*/
|
||||||
|
private func isNotThrottled() -> Bool {
|
||||||
|
guard Preferences.isEnabled(.automaticBackgroundUpdateCheck) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let minimumTimeAgo = Date().addingTimeInterval(-Constants.MinimumUpdateCheckInterval)
|
||||||
|
let lastCheckTime = UserDefaults.standard.object(
|
||||||
|
forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue
|
||||||
|
) as? Date
|
||||||
|
|
||||||
|
// If no previous check or last check was > minimum time frame, should check now
|
||||||
|
return lastCheckTime == nil || lastCheckTime! < minimumTimeAgo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Schedule a timer to perform an update check after the specified interval.
|
||||||
|
*/
|
||||||
|
private func scheduleTimer(after interval: TimeInterval = Constants.AutomaticUpdateCheckInterval) {
|
||||||
|
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||||
|
Task {
|
||||||
|
Log.info("Performing scheduled update check after \(interval) seconds.")
|
||||||
|
await self.performUpdateCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.info("A new update check will occur in \(interval) seconds from now.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ extension MainMenu {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for updates
|
// Check for updates
|
||||||
await performAutomaticUpdateCheck()
|
await UpdateScheduler.shared.startAutomaticUpdateChecking()
|
||||||
|
|
||||||
// Check if the linked version has changed between launches of phpmon
|
// Check if the linked version has changed between launches of phpmon
|
||||||
await PhpGuard().compareToLastGlobalVersion()
|
await PhpGuard().compareToLastGlobalVersion()
|
||||||
@@ -205,60 +205,4 @@ extension MainMenu {
|
|||||||
|
|
||||||
Log.info("Detected applications: \(appNames)")
|
Log.info("Detected applications: \(appNames)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
Perform an automatic update check and schedule the next one.
|
|
||||||
*/
|
|
||||||
private func performAutomaticUpdateCheck() async {
|
|
||||||
guard Preferences.isEnabled(.automaticBackgroundUpdateCheck) else {
|
|
||||||
// The user has chosen not to receive update notifications
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard automaticUpdateCheckIsNotThrottled() else {
|
|
||||||
// If we are throttled, just schedule a regular check 24 hours from now
|
|
||||||
scheduleUpdateCheckTimer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await AppUpdater().checkForUpdates(userInitiated: false)
|
|
||||||
|
|
||||||
UserDefaults.standard.set(Date(), forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue)
|
|
||||||
|
|
||||||
scheduleUpdateCheckTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Determine whether another automatic update check should occur based on the last check timestamp.
|
|
||||||
Returns true if a check should happen, false otherwise.
|
|
||||||
*/
|
|
||||||
private func automaticUpdateCheckIsNotThrottled() -> Bool {
|
|
||||||
guard Preferences.isEnabled(.automaticBackgroundUpdateCheck) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let minimumTimeAgo = Date().addingTimeInterval(-Constants.MinimumUpdateCheckInterval)
|
|
||||||
let lastCheckTime = UserDefaults.standard.object(
|
|
||||||
forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue
|
|
||||||
) as? Date
|
|
||||||
|
|
||||||
// If no previous check or last check was > minimum time frame, should check now
|
|
||||||
return lastCheckTime == nil || lastCheckTime! < minimumTimeAgo
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Schedule a timer to perform an update check after the specified interval.
|
|
||||||
*/
|
|
||||||
private func scheduleUpdateCheckTimer() {
|
|
||||||
let interval = Constants.AutomaticUpdateCheckInterval
|
|
||||||
|
|
||||||
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
|
||||||
Task {
|
|
||||||
Log.info("Performing scheduled update check after \(interval) seconds.")
|
|
||||||
await self.performAutomaticUpdateCheck()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.info("A new update check will occur in \(interval) seconds from now.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ enum PersistentAppState: String {
|
|||||||
case wasLaunchedBefore = "launched_before"
|
case wasLaunchedBefore = "launched_before"
|
||||||
case lastAutomaticUpdateCheck = "last_automatic_update_check"
|
case lastAutomaticUpdateCheck = "last_automatic_update_check"
|
||||||
case userFavorites = "user_favorites"
|
case userFavorites = "user_favorites"
|
||||||
|
case updateCheckFailureCount = "update_check_failure_count"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ class AppearancePreferencesVC: GenericPreferenceVC {
|
|||||||
|
|
||||||
class MenuStructurePreferencesVC: GenericPreferenceVC {
|
class MenuStructurePreferencesVC: GenericPreferenceVC {
|
||||||
|
|
||||||
// swiftlint:disable line_length
|
|
||||||
public static func fromStoryboard() -> GenericPreferenceVC {
|
public static func fromStoryboard() -> GenericPreferenceVC {
|
||||||
let vc = NSStoryboard(name: "Main", bundle: nil)
|
let vc = NSStoryboard(name: "Main", bundle: nil)
|
||||||
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
|
.instantiateController(withIdentifier: "preferencesTemplateVC") as! GenericPreferenceVC
|
||||||
@@ -67,7 +66,6 @@ class MenuStructurePreferencesVC: GenericPreferenceVC {
|
|||||||
.addView(when: true, vc.displayFeature("prefs.display_misc", .displayMisc))
|
.addView(when: true, vc.displayFeature("prefs.display_misc", .displayMisc))
|
||||||
.addView(when: true, vc.displayFeature("prefs.display_driver", .displayDriver))
|
.addView(when: true, vc.displayFeature("prefs.display_driver", .displayDriver))
|
||||||
}
|
}
|
||||||
// swiftlint:enable line_length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotificationPreferencesVC: GenericPreferenceVC {
|
class NotificationPreferencesVC: GenericPreferenceVC {
|
||||||
|
|||||||
126
phpmon/Domain/Services/AutomaticUpdateService.swift
Normal file
126
phpmon/Domain/Services/AutomaticUpdateService.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//
|
||||||
|
// AutomaticUpdateService.swift
|
||||||
|
// PHP Monitor
|
||||||
|
//
|
||||||
|
// Created by Nico Verbruggen on 26/09/2025.
|
||||||
|
// Copyright © 2025 Nico Verbruggen. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor UpdateScheduler {
|
||||||
|
static let shared = UpdateScheduler()
|
||||||
|
|
||||||
|
private var currentTimer: Timer?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Start the automatic update checking process. This should be called once during app startup.
|
||||||
|
*/
|
||||||
|
func startAutomaticUpdateChecking() async {
|
||||||
|
await performUpdateCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Perform an automatic update check and schedule the next one.
|
||||||
|
*/
|
||||||
|
private func performUpdateCheck() async {
|
||||||
|
guard isNotThrottled() else {
|
||||||
|
// If we are throttled, just schedule a regular check 24 hours from now.
|
||||||
|
scheduleTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await AppUpdater().checkForUpdates(userInitiated: false)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
// Reset failure count and record successful check
|
||||||
|
UserDefaults.standard.removeObject(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue)
|
||||||
|
scheduleTimer()
|
||||||
|
Log.info("Update check succeeded. Next check in \(Constants.AutomaticUpdateCheckInterval)s.")
|
||||||
|
|
||||||
|
case .disabled:
|
||||||
|
// User disabled automatic checks, don't schedule another
|
||||||
|
Log.info("Automatic update checks disabled. No further checks scheduled.")
|
||||||
|
|
||||||
|
case .networkError, .parseError:
|
||||||
|
// Handle failures with exponential backoff
|
||||||
|
handleFailure(result: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Handle update check failures with exponential backoff retry logic.
|
||||||
|
*/
|
||||||
|
private func handleFailure(result: UpdateCheckResult) {
|
||||||
|
let currentFailureCount = UserDefaults.standard.integer(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
let newFailureCount = currentFailureCount + 1
|
||||||
|
|
||||||
|
UserDefaults.standard.set(newFailureCount, forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
|
||||||
|
let retryInterval: TimeInterval
|
||||||
|
if newFailureCount <= Constants.UpdateCheckRetryIntervals.count {
|
||||||
|
// Use exponential backoff
|
||||||
|
retryInterval = Constants.UpdateCheckRetryIntervals[newFailureCount - 1]
|
||||||
|
Log.info("Update check failed (\(result)). Retry \(newFailureCount) in \(retryInterval)s.")
|
||||||
|
} else {
|
||||||
|
// Exceeded max retries, fall back to normal schedule and reset counter
|
||||||
|
retryInterval = Constants.AutomaticUpdateCheckInterval
|
||||||
|
UserDefaults.standard.removeObject(forKey: PersistentAppState.updateCheckFailureCount.rawValue)
|
||||||
|
Log.info("Update check failed (\(result)). Max retries exceeded. Normal schedule in \(retryInterval)s.")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleTimer(after: retryInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Determine whether another automatic update check should occur based on the last check timestamp.
|
||||||
|
Returns true if a check should happen, false otherwise.
|
||||||
|
*/
|
||||||
|
private func isNotThrottled() -> Bool {
|
||||||
|
guard Preferences.isEnabled(.automaticBackgroundUpdateCheck) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let minimumTimeAgo = Date().addingTimeInterval(-Constants.MinimumUpdateCheckInterval)
|
||||||
|
let lastCheckTime = UserDefaults.standard.object(
|
||||||
|
forKey: PersistentAppState.lastAutomaticUpdateCheck.rawValue
|
||||||
|
) as? Date
|
||||||
|
|
||||||
|
// If no previous check or last check was > minimum time frame, should check now
|
||||||
|
return lastCheckTime == nil || lastCheckTime! < minimumTimeAgo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Schedule a timer to perform an update check after the specified interval.
|
||||||
|
*/
|
||||||
|
private func scheduleTimer(after interval: TimeInterval = Constants.AutomaticUpdateCheckInterval) {
|
||||||
|
// Invalidate any existing timer
|
||||||
|
currentTimer?.invalidate()
|
||||||
|
|
||||||
|
// Ensure timer is scheduled on main run loop since actors run on background threads
|
||||||
|
Task { @MainActor in
|
||||||
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||||
|
Task {
|
||||||
|
Log.info("Performing scheduled update check after \(interval)s.")
|
||||||
|
await self.performUpdateCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store timer reference back in actor
|
||||||
|
await self.setCurrentTimer(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.info("Next update check scheduled in \(interval)s.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Set the current timer reference. Used to store timer from main thread back to actor.
|
||||||
|
*/
|
||||||
|
private func setCurrentTimer(_ timer: Timer) {
|
||||||
|
currentTimer = timer
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// swiftlint:disable colon
|
||||||
class TestableConfigurations {
|
class TestableConfigurations {
|
||||||
/** A functional, working system setup that is compatible with PHP Monitor. */
|
/** A functional, working system setup that is compatible with PHP Monitor. */
|
||||||
static var working: TestableConfiguration {
|
static var working: TestableConfiguration {
|
||||||
@@ -179,7 +180,9 @@ class TestableConfigurations {
|
|||||||
: .delayed(0.2, "OK"),
|
: .delayed(0.2, "OK"),
|
||||||
"ln -sF ~/.config/valet/valet84.sock ~/.config/valet/valet.sock"
|
"ln -sF ~/.config/valet/valet84.sock ~/.config/valet/valet.sock"
|
||||||
: .instant("OK"),
|
: .instant("OK"),
|
||||||
"/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae": .delayed(2.0, """
|
"/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae"
|
||||||
|
: .delayed(2.0,
|
||||||
|
"""
|
||||||
{
|
{
|
||||||
"formulae": [
|
"formulae": [
|
||||||
{
|
{
|
||||||
@@ -199,7 +202,7 @@ class TestableConfigurations {
|
|||||||
commandOutput: [
|
commandOutput: [
|
||||||
"/opt/homebrew/bin/php -r echo ini_get('memory_limit');": "512M",
|
"/opt/homebrew/bin/php -r echo ini_get('memory_limit');": "512M",
|
||||||
"/opt/homebrew/bin/php -r echo ini_get('upload_max_filesize');": "512M",
|
"/opt/homebrew/bin/php -r echo ini_get('upload_max_filesize');": "512M",
|
||||||
"/opt/homebrew/bin/php -r echo ini_get('post_max_size');": "512M",
|
"/opt/homebrew/bin/php -r echo ini_get('post_max_size');": "512M"
|
||||||
],
|
],
|
||||||
preferenceOverrides: [
|
preferenceOverrides: [
|
||||||
.automaticBackgroundUpdateCheck: false
|
.automaticBackgroundUpdateCheck: false
|
||||||
@@ -223,6 +226,7 @@ class TestableConfigurations {
|
|||||||
return configuration
|
return configuration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// swiftlint:enable colon
|
||||||
|
|
||||||
class ShellStrings {
|
class ShellStrings {
|
||||||
static var shared = ShellStrings()
|
static var shared = ShellStrings()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class FeatureTestCase: XCTestCase {
|
|||||||
return fs as! TestableFileSystem
|
return fs as! TestableFileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
fatalError("The active filesystem is not a TestableFileSystem. Please use `ActiveFileSystem` to use the fake filesystem.")
|
fatalError("The active filesystem is not a TestableFileSystem. Please use `ActiveFileSystem`.")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func assertFileSystemHas(
|
public func assertFileSystemHas(
|
||||||
@@ -44,6 +44,4 @@ class FeatureTestCase: XCTestCase {
|
|||||||
) {
|
) {
|
||||||
XCTAssertEqual(contents, fakeFileSystem.files[path]?.content, file: file, line: line)
|
XCTAssertEqual(contents, fakeFileSystem.files[path]?.content, file: file, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class DomainsListTest: UITestCase {
|
|||||||
searchField.click()
|
searchField.click()
|
||||||
searchField.typeText("non-existent thing")
|
searchField.typeText("non-existent thing")
|
||||||
Thread.sleep(forTimeInterval: 0.2)
|
Thread.sleep(forTimeInterval: 0.2)
|
||||||
XCTAssertTrue(window.tables.tableRows.count == 0)
|
XCTAssertTrue(window.tables.tableRows.count == 0) // swiftlint:disable:this empty_count
|
||||||
|
|
||||||
searchField.clearText()
|
searchField.clearText()
|
||||||
searchField.click()
|
searchField.click()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ final class StartupTest: UITestCase {
|
|||||||
assertAllExist([
|
assertAllExist([
|
||||||
app.dialogs["generic.notice".localized],
|
app.dialogs["generic.notice".localized],
|
||||||
app.staticTexts["startup.errors.php_binary.title".localized],
|
app.staticTexts["startup.errors.php_binary.title".localized],
|
||||||
app.buttons["generic.ok".localized],
|
app.buttons["generic.ok".localized]
|
||||||
])
|
])
|
||||||
click(app.buttons["generic.ok".localized])
|
click(app.buttons["generic.ok".localized])
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class ExtensionEnumeratorTest: XCTestCase {
|
|||||||
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.1.rb": .fake(.text, "<test>"),
|
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.1.rb": .fake(.text, "<test>"),
|
||||||
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.2.rb": .fake(.text, "<test>"),
|
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.2.rb": .fake(.text, "<test>"),
|
||||||
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.3.rb": .fake(.text, "<test>"),
|
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.3.rb": .fake(.text, "<test>"),
|
||||||
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.4.rb": .fake(.text, "<test>"),
|
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.4.rb": .fake(.text, "<test>")
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,5 +37,4 @@ final class ExtensionEnumeratorTest: XCTestCase {
|
|||||||
XCTAssertEqual(formulae["8.3"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.3")])
|
XCTAssertEqual(formulae["8.3"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.3")])
|
||||||
XCTAssertEqual(formulae["8.4"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.4")])
|
XCTAssertEqual(formulae["8.4"], [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.4")])
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class ValetRcTest: XCTestCase {
|
|||||||
.url(forResource: "valetrc", withExtension: "broken")!
|
.url(forResource: "valetrc", withExtension: "broken")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
func test_can_extract_fields_from_valetrc_file() throws {
|
func test_can_extract_fields_from_valetrc_file() throws {
|
||||||
|
|||||||
@@ -71,8 +71,6 @@ class RealFileSystemTest: XCTestCase {
|
|||||||
return executablePath
|
return executablePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func test_can_read_file_as_text() {
|
func test_can_read_file_as_text() {
|
||||||
let temporaryDirectory = self.createUniqueTemporaryDirectory()
|
let temporaryDirectory = self.createUniqueTemporaryDirectory()
|
||||||
let executable = self.createTestBinaryFile(temporaryDirectory)
|
let executable = self.createTestBinaryFile(temporaryDirectory)
|
||||||
|
|||||||
@@ -38,4 +38,3 @@ class TestableConfigurationTest: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
// swiftlint:disable type_body_length
|
// swiftlint:disable type_body_length file_length
|
||||||
class PhpVersionNumberTest: XCTestCase {
|
class PhpVersionNumberTest: XCTestCase {
|
||||||
|
|
||||||
func test_can_deconstruct_php_version() throws {
|
func test_can_deconstruct_php_version() throws {
|
||||||
@@ -51,7 +51,6 @@ class PhpVersionNumberTest: XCTestCase {
|
|||||||
XCTAssertEqual(version!.minor, 0)
|
XCTAssertEqual(version!.minor, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func test_can_check_wildcard_version_constraint() throws {
|
func test_can_check_wildcard_version_constraint() throws {
|
||||||
// Wildcard for patch only
|
// Wildcard for patch only
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
@@ -408,3 +407,4 @@ class PhpVersionNumberTest: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// swiftlint:enable type_body_length file_length
|
||||||
|
|||||||
Reference in New Issue
Block a user