mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
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:
parent
86492c4cea
commit
f2f5f5f67d
9 changed files with 325 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
mac/VibeTunnel/Core/Services/PowerManagementService.swift
Normal file
76
mac/VibeTunnel/Core/Services/PowerManagementService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
166
mac/VibeTunnelTests/PowerManagementServiceTests.swift
Normal file
166
mac/VibeTunnelTests/PowerManagementServiceTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue