From f2f5f5f67dd5a8d8fa714dea29e3512118188aa0 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Mon, 30 Jun 2025 12:13:21 +0200 Subject: [PATCH] feat: add sleep prevention option to keep Mac awake during sessions (#146) Co-authored-by: Claude Co-authored-by: Peter Steinberger --- mac/CHANGELOG.md | 18 ++ mac/VibeTunnel/Core/Models/AppConstants.swift | 21 +++ .../Services/PowerManagementService.swift | 76 ++++++++ .../Core/Services/ServerManager.swift | 32 +++- .../Views/Settings/GeneralSettingsView.swift | 10 ++ mac/VibeTunnel/version.xcconfig | 4 +- .../PowerManagementServiceTests.swift | 166 ++++++++++++++++++ mac/VibeTunnelTests/Utilities/TestTags.swift | 1 + web/package.json | 2 +- 9 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 mac/VibeTunnel/Core/Services/PowerManagementService.swift create mode 100644 mac/VibeTunnelTests/PowerManagementServiceTests.swift diff --git a/mac/CHANGELOG.md b/mac/CHANGELOG.md index ecd7958b..bba78d2e 100644 --- a/mac/CHANGELOG.md +++ b/mac/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.0.0-beta.6] - 2025-01-30 + +### ✨ New Features +- **Sleep Prevention** - Mac now stays awake when VibeTunnel is running terminal sessions +- **Dynamic Settings** - Sleep prevention setting updates immediately without server restart +- **Terminal Title Management** - Enhanced terminal title updates with activity detection +- **Prompt Pattern Detection** - Optimized prompt detection with unified regex patterns and caching + +### 🐛 Bug Fixes +- **Unicode Cursor Positioning** - Fixed cursor positioning issues with Unicode characters +- **Race Condition** - Fixed sleep prevention race condition during server startup +- **Memory Efficiency** - Added input validation to prevent excessive memory usage on long inputs + +### 🔧 Technical Improvements +- **Performance** - 45% faster prompt pattern detection with LRU caching +- **Code Quality** - Comprehensive test coverage for prompt detection utilities +- **Documentation** - Improved regex documentation for maintainability + ## [1.0.0-beta.5] - 2025-01-29 ### 🎨 UI Improvements diff --git a/mac/VibeTunnel/Core/Models/AppConstants.swift b/mac/VibeTunnel/Core/Models/AppConstants.swift index 9bd14fd3..77e0ca4e 100644 --- a/mac/VibeTunnel/Core/Models/AppConstants.swift +++ b/mac/VibeTunnel/Core/Models/AppConstants.swift @@ -9,5 +9,26 @@ enum AppConstants { /// UserDefaults keys enum UserDefaultsKeys { static let welcomeVersion = "welcomeVersion" + static let preventSleepWhenRunning = "preventSleepWhenRunning" + } + + /// Default values for UserDefaults + enum Defaults { + /// Sleep prevention is enabled by default for better user experience + static let preventSleepWhenRunning = true + } + + /// Helper to get boolean value with proper default + static func boolValue(for key: String) -> Bool { + // If the key doesn't exist in UserDefaults, return our default + if UserDefaults.standard.object(forKey: key) == nil { + switch key { + case UserDefaultsKeys.preventSleepWhenRunning: + return Defaults.preventSleepWhenRunning + default: + return false + } + } + return UserDefaults.standard.bool(forKey: key) } } diff --git a/mac/VibeTunnel/Core/Services/PowerManagementService.swift b/mac/VibeTunnel/Core/Services/PowerManagementService.swift new file mode 100644 index 00000000..8a6ac85d --- /dev/null +++ b/mac/VibeTunnel/Core/Services/PowerManagementService.swift @@ -0,0 +1,76 @@ +import Foundation +import IOKit.pwr_mgt +import Observation + +/// Manages system power assertions to prevent the Mac from sleeping while VibeTunnel is running. +/// +/// This service uses IOKit's power management APIs to create power assertions that prevent +/// the system from entering idle sleep when terminal sessions are active. The service is +/// integrated with ServerManager to automatically manage sleep prevention based on server +/// state and user preferences. +@Observable +@MainActor +final class PowerManagementService { + static let shared = PowerManagementService() + + private(set) var isSleepPrevented = false + + private var assertionID: IOPMAssertionID = 0 + private var isAssertionActive = false + + private init() {} + + /// Prevents the system from sleeping + func preventSleep() { + guard !isAssertionActive else { return } + + let reason = "VibeTunnel is running terminal sessions" as CFString + let assertionType = kIOPMAssertionTypeNoIdleSleep as CFString + + let success = IOPMAssertionCreateWithName( + assertionType, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + reason, + &assertionID + ) + + if success == kIOReturnSuccess { + isAssertionActive = true + isSleepPrevented = true + print("Sleep prevention enabled") + } else { + print("Failed to prevent sleep: \(success)") + } + } + + /// Allows the system to sleep normally + func allowSleep() { + guard isAssertionActive else { return } + + let success = IOPMAssertionRelease(assertionID) + + if success == kIOReturnSuccess { + isAssertionActive = false + isSleepPrevented = false + assertionID = 0 + print("Sleep prevention disabled") + } else { + print("Failed to release sleep assertion: \(success)") + } + } + + /// Updates sleep prevention based on user preference and server state + func updateSleepPrevention(enabled: Bool, serverRunning: Bool) { + if enabled && serverRunning { + preventSleep() + } else { + allowSleep() + } + } + + deinit { + // Deinit runs on arbitrary thread, but we need to check MainActor state + // Since we can't access MainActor properties directly in deinit, + // we handle cleanup in allowSleep() which is called when server stops + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index f8112324..7f2c767f 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -92,6 +92,7 @@ class ServerManager { private var lastCrashTime: Date? private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerManager") + private let powerManager = PowerManagementService.shared private init() { // Skip observer setup and monitoring during tests @@ -113,7 +114,7 @@ class ServerManager { } private func setupObservers() { - // Watch for server mode changes when the value actually changes + // Watch for UserDefaults changes (e.g., sleep prevention setting) NotificationCenter.default.addObserver( self, selector: #selector(userDefaultsDidChange), @@ -124,7 +125,16 @@ class ServerManager { @objc private nonisolated func userDefaultsDidChange() { - // No server-related defaults to monitor + Task { @MainActor in + // Only update sleep prevention if server is running + guard isRunning else { return } + + // Check if preventSleepWhenRunning setting changed + let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true) + + logger.info("Updated sleep prevention setting: \(preventSleep ? "enabled" : "disabled")") + } } /// Start the server with current configuration @@ -212,6 +222,12 @@ class ServerManager { bunServer = server // Check server state to ensure it's actually running if server.getState() == .running { + // Update sleep prevention FIRST before updating state + // This prevents a race condition where the server could crash after setting isRunning = true + let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true) + + // Now update state isRunning = true lastError = nil // Reset crash counter on successful start @@ -260,6 +276,9 @@ class ServerManager { // Clear the auth token from SessionMonitor SessionMonitor.shared.setLocalAuthToken(nil) + + // Allow sleep when server is stopped + powerManager.updateSleepPrevention(enabled: false, serverRunning: false) // Reset crash tracking when manually stopped consecutiveCrashes = 0 @@ -377,6 +396,9 @@ class ServerManager { // Update state immediately isRunning = false bunServer = nil + + // Allow sleep when server crashes + powerManager.updateSleepPrevention(enabled: false, serverRunning: false) // Prevent multiple simultaneous crash handlers guard !isHandlingCrash else { @@ -489,6 +511,12 @@ class ServerManager { /// Monitor server health periodically func startHealthMonitoring() { Task { + // Check initial state on app launch + if let server = bunServer, server.getState() == .running { + let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true) + } + while true { try? await Task.sleep(for: .seconds(30)) diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index 7d023528..7a2d1e13 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -8,6 +8,8 @@ struct GeneralSettingsView: View { private var showNotifications = true @AppStorage("updateChannel") private var updateChannelRaw = UpdateChannel.stable.rawValue + @AppStorage("preventSleepWhenRunning") + private var preventSleepWhenRunning = true @State private var isCheckingForUpdates = false @@ -28,6 +30,14 @@ struct GeneralSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + // Prevent Sleep + VStack(alignment: .leading, spacing: 4) { + Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning) + Text("Keep your Mac awake while VibeTunnel sessions are active.") + .font(.caption) + .foregroundStyle(.secondary) + } } header: { Text("Application") .font(.headline) diff --git a/mac/VibeTunnel/version.xcconfig b/mac/VibeTunnel/version.xcconfig index 766df0d6..0a39b26e 100644 --- a/mac/VibeTunnel/version.xcconfig +++ b/mac/VibeTunnel/version.xcconfig @@ -1,8 +1,8 @@ // VibeTunnel Version Configuration // This file contains the version and build number for the app -MARKETING_VERSION = 1.0.0-beta.5 -CURRENT_PROJECT_VERSION = 150 +MARKETING_VERSION = 1.0.0-beta.6 +CURRENT_PROJECT_VERSION = 151 // Domain and GitHub configuration APP_DOMAIN = vibetunnel.sh diff --git a/mac/VibeTunnelTests/PowerManagementServiceTests.swift b/mac/VibeTunnelTests/PowerManagementServiceTests.swift new file mode 100644 index 00000000..f3a9947c --- /dev/null +++ b/mac/VibeTunnelTests/PowerManagementServiceTests.swift @@ -0,0 +1,166 @@ +import Testing +import Foundation +@testable import VibeTunnel + +/// Tests for PowerManagementService that work reliably in CI environments +@Suite("Power Management Service") +@MainActor +struct PowerManagementServiceTests { + + // Since PowerManagementService has a private init, we can only test through the shared instance + // We need to ensure proper cleanup between tests + + @Test("Sleep prevention defaults to true when key doesn't exist") + func sleepPreventionDefaultValue() async { + // Save current value + let currentValue = UserDefaults.standard.object(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + defer { + // Restore original value + if let currentValue = currentValue { + UserDefaults.standard.set(currentValue, forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + } else { + UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + } + } + + // Remove the key to simulate first launch + UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + + // Test our helper method returns true for non-existent key + let defaultValue = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + #expect(defaultValue == true, "Sleep prevention should default to true when key doesn't exist") + + // Verify UserDefaults.standard.bool returns false (the bug we're fixing) + let standardDefault = UserDefaults.standard.bool(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning) + #expect(standardDefault == false, "UserDefaults.standard.bool returns false for non-existent keys") + } + + @Test("Update sleep prevention logic with all combinations") + func updateSleepPreventionLogic() async { + let service = PowerManagementService.shared + + // Ensure clean state + service.allowSleep() + + // Test Case 1: Both enabled and server running should prevent sleep + service.updateSleepPrevention(enabled: true, serverRunning: true) + #expect(service.isSleepPrevented) + + // Test Case 2: Disabled setting should allow sleep + service.updateSleepPrevention(enabled: false, serverRunning: true) + #expect(!service.isSleepPrevented) + + // Test Case 3: Server not running should allow sleep + service.updateSleepPrevention(enabled: true, serverRunning: false) + #expect(!service.isSleepPrevented) + + // Test Case 4: Both false should allow sleep + service.updateSleepPrevention(enabled: false, serverRunning: false) + #expect(!service.isSleepPrevented) + + // Cleanup + service.allowSleep() + } + + @Test("Multiple prevent sleep calls are idempotent") + func preventSleepIdempotency() async { + let service = PowerManagementService.shared + + // Ensure clean state + service.allowSleep() + + // Call preventSleep multiple times + service.preventSleep() + let firstState = service.isSleepPrevented + + service.preventSleep() + service.preventSleep() + + // State should remain the same + #expect(service.isSleepPrevented == firstState) + + // Cleanup + service.allowSleep() + } + + @Test("Multiple allow sleep calls are idempotent") + func allowSleepIdempotency() async { + let service = PowerManagementService.shared + + // Set up initial state + service.preventSleep() + + // Call allowSleep multiple times + service.allowSleep() + #expect(!service.isSleepPrevented) + + service.allowSleep() + service.allowSleep() + + // State should remain false + #expect(!service.isSleepPrevented) + } + + @Test("State transitions work correctly") + func stateTransitions() async { + let service = PowerManagementService.shared + + // Ensure clean state + service.allowSleep() + #expect(!service.isSleepPrevented) + + // Prevent sleep + service.preventSleep() + #expect(service.isSleepPrevented) + + // Allow sleep again + service.allowSleep() + #expect(!service.isSleepPrevented) + + // Use updateSleepPrevention + service.updateSleepPrevention(enabled: true, serverRunning: true) + #expect(service.isSleepPrevented) + + service.updateSleepPrevention(enabled: false, serverRunning: false) + #expect(!service.isSleepPrevented) + + // Cleanup + service.allowSleep() + } +} + +// MARK: - Edge Cases + +@Suite("Power Management Edge Cases") +@MainActor +struct PowerManagementEdgeCaseTests { + + @Test("Rapid state changes handle correctly") + func rapidStateChanges() async { + let service = PowerManagementService.shared + + // Ensure clean state + service.allowSleep() + + // Rapidly toggle state + for _ in 0..<10 { + service.preventSleep() + service.allowSleep() + } + + // Final state should be sleep allowed + #expect(!service.isSleepPrevented) + + // Now rapidly toggle with updateSleepPrevention + for i in 0..<10 { + let enabled = i % 2 == 0 + service.updateSleepPrevention(enabled: enabled, serverRunning: true) + } + + // Final state should match last call (i=9, odd, so enabled=false) + #expect(!service.isSleepPrevented) + + // Cleanup + service.allowSleep() + } +} \ No newline at end of file diff --git a/mac/VibeTunnelTests/Utilities/TestTags.swift b/mac/VibeTunnelTests/Utilities/TestTags.swift index dfab6e57..22ebdf08 100644 --- a/mac/VibeTunnelTests/Utilities/TestTags.swift +++ b/mac/VibeTunnelTests/Utilities/TestTags.swift @@ -13,4 +13,5 @@ extension Tag { @Tag static var integration: Self @Tag static var regression: Self @Tag static var sessionManagement: Self + @Tag static var serverManager: Self } diff --git a/web/package.json b/web/package.json index c7ee4d6e..14011c4c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@vibetunnel/vibetunnel-cli", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "description": "Web frontend for terminal multiplexer", "main": "dist/server.js", "bin": {