feat: add sleep prevention option to keep Mac awake during sessions (#146)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Helmut Januschka 2025-06-30 12:13:21 +02:00 committed by GitHub
parent 86492c4cea
commit f2f5f5f67d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 325 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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