From 11911ebc8ffd1f88a8676597735566cd04f3d1c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Jun 2025 23:52:41 +0200 Subject: [PATCH] add force kill buttom and Sparkle user driver --- .../Core/Services/SparkleUserDriver.swift | 247 ++++++++++++++++++ .../Core/Utilities/PortConflictResolver.swift | 22 ++ .../Settings/DashboardSettingsView.swift | 3 +- 3 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 VibeTunnel/Core/Services/SparkleUserDriver.swift diff --git a/VibeTunnel/Core/Services/SparkleUserDriver.swift b/VibeTunnel/Core/Services/SparkleUserDriver.swift new file mode 100644 index 00000000..2f7475d1 --- /dev/null +++ b/VibeTunnel/Core/Services/SparkleUserDriver.swift @@ -0,0 +1,247 @@ +import AppKit +import Foundation +import Sparkle +import os.log + +/// Custom user driver for Sparkle updates that implements gentle reminders +@MainActor +public final class SparkleUserDriver: NSObject, SPUUserDriverDelegate { + private let logger = os.Logger( + subsystem: Bundle.main.bundleIdentifier ?? "VibeTunnel", + category: "SparkleUserDriver" + ) + + private var updateItem: SUAppcastItem? + private var userDriver: SPUUserDriver? + private var reminderTimer: Timer? + private var lastReminderDate: Date? + + // Configuration + private let initialReminderDelay: TimeInterval = 60 * 60 * 24 // 24 hours + private let subsequentReminderInterval: TimeInterval = 60 * 60 * 24 * 3 // 3 days + + public override init() { + super.init() + } + + // MARK: - SPUUserDriverDelegate + + public func showCanCheck(forUpdates updater: SPUUpdater) { + logger.info("User can check for updates") + } + + public func showUpdatePermissionRequest(for updater: SPUUpdater, systemProfile: [String : Any], reply: @escaping (SPUUpdatePermissionResponse) -> Void) { + // Show permission dialog + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Check for Updates Automatically?" + alert.informativeText = "VibeTunnel can automatically check for updates. You can always check for updates manually from the menu." + alert.addButton(withTitle: "Check Automatically") + alert.addButton(withTitle: "Don't Check") + + let response = alert.runModal() + + reply(SPUUpdatePermissionResponse( + automaticUpdateChecks: response == .alertFirstButtonReturn, + sendSystemProfile: false + )) + } + } + + public func showUserInitiatedUpdateCheck(completion updateCheckStatusCompletion: @escaping (SPUUserInitiatedCheckStatus) -> Void) { + logger.info("User initiated update check") + updateCheckStatusCompletion(.checkEnabled) + } + + public func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUpdateState, reply: @escaping (SPUUpdateAlertChoice) -> Void) { + logger.info("Update found: \(appcastItem.displayVersionString ?? "Unknown version")") + + // Store the update item for gentle reminders + self.updateItem = appcastItem + + // Cancel any existing reminder timer + reminderTimer?.invalidate() + + // Show immediate notification + showUpdateNotification(appcastItem: appcastItem, state: state, reply: reply, isReminder: false) + + // Schedule the first gentle reminder + scheduleGentleReminder(appcastItem: appcastItem, state: state) + } + + public func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + // Handle release notes display if needed + logger.info("Showing update release notes") + } + + public func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) { + logger.error("Failed to download release notes: \(error)") + } + + public func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { + logger.info("No updates found") + acknowledgement() + } + + public func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { + logger.error("Updater error: \(error)") + + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Update Error" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + + acknowledgement() + } + } + + public func showDownloadInitiated(cancellation: @escaping () -> Void) { + logger.info("Download initiated") + // Could show a download progress indicator here + } + + public func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + logger.info("Download expected content length: \(expectedContentLength)") + } + + public func showDownloadDidReceiveData(ofLength length: UInt64) { + // Update download progress if showing progress UI + } + + public func showDownloadDidStartExtractingUpdate() { + logger.info("Extracting update") + } + + public func showExtractionReceivedProgress(_ progress: Double) { + // Update extraction progress if showing progress UI + } + + public func showReady(toInstallAndRelaunch reply: @escaping (SPUUpdateAlertChoice) -> Void) { + logger.info("Ready to install and relaunch") + + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Update Ready to Install" + alert.informativeText = "VibeTunnel is ready to install the update and relaunch." + alert.addButton(withTitle: "Install and Relaunch") + alert.addButton(withTitle: "Install Later") + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + reply(.installUpdateChoice) + } else { + reply(.installLaterChoice) + } + } + } + + public func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { + logger.info("Installing update, application terminated: \(applicationTerminated)") + } + + public func showSendingTerminationSignal() { + logger.info("Sending termination signal") + } + + public func showUpdateInstallationDidFinish(acknowledgement: @escaping () -> Void) { + logger.info("Update installation finished") + acknowledgement() + } + + public func dismissUpdateInstallation() { + logger.info("Dismissing update installation") + + // Cancel any pending reminders + reminderTimer?.invalidate() + reminderTimer = nil + updateItem = nil + } + + // MARK: - Gentle Reminders + + private func scheduleGentleReminder(appcastItem: SUAppcastItem, state: SPUUpdateState) { + // Determine the delay for the next reminder + let delay: TimeInterval + if lastReminderDate == nil { + // First reminder + delay = initialReminderDelay + } else { + // Subsequent reminders + delay = subsequentReminderInterval + } + + logger.info("Scheduling gentle reminder in \(delay / 3600) hours") + + reminderTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + self?.showGentleReminder(appcastItem: appcastItem, state: state) + } + } + + private func showGentleReminder(appcastItem: SUAppcastItem, state: SPUUpdateState) { + logger.info("Showing gentle reminder for update") + + lastReminderDate = Date() + + // Show the update notification as a reminder + showUpdateNotification(appcastItem: appcastItem, state: state, reply: { [weak self] choice in + if choice == .installUpdateChoice { + // User chose to install, no more reminders needed + self?.reminderTimer?.invalidate() + self?.reminderTimer = nil + } else { + // Schedule the next reminder + self?.scheduleGentleReminder(appcastItem: appcastItem, state: state) + } + }, isReminder: true) + } + + private func showUpdateNotification(appcastItem: SUAppcastItem, state: SPUUpdateState, reply: @escaping (SPUUpdateAlertChoice) -> Void, isReminder: Bool) { + DispatchQueue.main.async { + // Create a more prominent alert for updates + let alert = NSAlert() + alert.messageText = isReminder ? "Update Reminder" : "Update Available" + + let versionString = appcastItem.displayVersionString ?? "new version" + var informativeText = "VibeTunnel \(versionString) is available." + + if isReminder { + informativeText += " You have a pending update ready to install." + } + + if let releaseNotesURL = appcastItem.releaseNotesURL { + informativeText += " Would you like to download it now?" + } + + alert.informativeText = informativeText + alert.alertStyle = .informational + + // Add buttons + alert.addButton(withTitle: state.stage == .downloaded ? "Install Update" : "Download Update") + alert.addButton(withTitle: "Skip This Version") + alert.addButton(withTitle: "Remind Me Later") + + // Make the window more prominent + if let window = alert.window { + window.level = .floating + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + reply(.installUpdateChoice) + case .alertSecondButtonReturn: + reply(.skipThisVersionChoice) + default: + reply(.installLaterChoice) + } + } + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Utilities/PortConflictResolver.swift b/VibeTunnel/Core/Utilities/PortConflictResolver.swift index 90c15186..7d1809ec 100644 --- a/VibeTunnel/Core/Utilities/PortConflictResolver.swift +++ b/VibeTunnel/Core/Utilities/PortConflictResolver.swift @@ -135,6 +135,28 @@ final class PortConflictResolver { } } + /// Force kill any process, regardless of type + func forceKillProcess(_ conflict: PortConflict) async throws { + logger.info("Force killing process: \(conflict.process.name) (PID: \(conflict.process.pid))") + + // Kill the process + let killProcess = Process() + killProcess.executableURL = URL(fileURLWithPath: "/bin/kill") + killProcess.arguments = ["-9", "\(conflict.process.pid)"] + + try killProcess.run() + killProcess.waitUntilExit() + + if killProcess.terminationStatus != 0 { + // Try with sudo if regular kill fails + logger.warning("Regular kill failed, attempting with elevated privileges") + throw PortConflictError.failedToKillProcess(pid: conflict.process.pid) + } + + // Wait a moment for port to be released + try await Task.sleep(for: .milliseconds(500)) + } + /// Find available ports near a given port func findAvailablePorts(near port: Int, count: Int) async -> [Int] { var availablePorts: [Int] = [] diff --git a/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index 4d3c2998..7598e1a9 100644 --- a/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -786,7 +786,8 @@ private struct PortConfigurationView: View { private func forceQuitConflictingProcess(_ conflict: PortConflict) async { do { - try await PortConflictResolver.shared.resolveConflict(conflict) + // Try to use forceKillProcess which works for any process + try await PortConflictResolver.shared.forceKillProcess(conflict) portConflict = nil // Restart server after clearing conflict restartServerWithNewPort(portNumber)