1
0
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:
2025-09-26 17:00:43 +02:00
parent 1a8fe7e7fc
commit 13013f2513
21 changed files with 309 additions and 86 deletions

View File

@@ -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

View File

@@ -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 */,

View File

@@ -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

View File

@@ -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() {

View 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.")
}
}

View File

@@ -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.")
}
} }

View File

@@ -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"
} }
/** /**

View File

@@ -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 {

View 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
}
}

View File

@@ -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()

View File

@@ -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)
} }
} }

View File

@@ -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()

View File

@@ -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])

View File

@@ -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")])
} }
} }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -30,7 +30,7 @@ class TestableShellTest: XCTestCase {
XCTAssertEqual("Hello world\nGoodbye world", output.out) XCTAssertEqual("Hello world\nGoodbye world", output.out)
} }
func test_fake_shell_synchronous_output() { func test_fake_shell_synchronous_output() {
let greeting = BatchFakeShellOutput(items: [ let greeting = BatchFakeShellOutput(items: [
.instant("Hello world\n"), .instant("Hello world\n"),

View File

@@ -12,7 +12,7 @@ class TestableConfigurationTest: XCTestCase {
func test_configuration_can_be_saved_as_json() async { func test_configuration_can_be_saved_as_json() async {
// WORKING // WORKING
var configuration = TestableConfigurations.working var configuration = TestableConfigurations.working
try! configuration.toJson().write( try! configuration.toJson().write(
toFile: NSHomeDirectory() + "/.phpmon_fconf_working.json", toFile: NSHomeDirectory() + "/.phpmon_fconf_working.json",
atomically: true, atomically: true,
@@ -38,4 +38,3 @@ class TestableConfigurationTest: XCTestCase {
) )
} }
} }

View File

@@ -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

View File

@@ -8,7 +8,7 @@
import Testing import Testing
@Suite("Commands") @Suite("Commands")
struct CommandTest { struct CommandTest {
@Test @Test

View File

@@ -18,7 +18,7 @@ struct ValetConfigurationTest {
)! )!
} }
@Test("Can load config file") @Test("Can load config file")
func can_load_config_file() throws { func can_load_config_file() throws {
let json = try? String( let json = try? String(
contentsOf: Self.jsonConfigFileUrl, contentsOf: Self.jsonConfigFileUrl,