mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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
|
# 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
|
## [1.0.0-beta.5] - 2025-01-29
|
||||||
|
|
||||||
### 🎨 UI Improvements
|
### 🎨 UI Improvements
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,26 @@ enum AppConstants {
|
||||||
/// UserDefaults keys
|
/// UserDefaults keys
|
||||||
enum UserDefaultsKeys {
|
enum UserDefaultsKeys {
|
||||||
static let welcomeVersion = "welcomeVersion"
|
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 var lastCrashTime: Date?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerManager")
|
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerManager")
|
||||||
|
private let powerManager = PowerManagementService.shared
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Skip observer setup and monitoring during tests
|
// Skip observer setup and monitoring during tests
|
||||||
|
|
@ -113,7 +114,7 @@ class ServerManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
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(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(userDefaultsDidChange),
|
selector: #selector(userDefaultsDidChange),
|
||||||
|
|
@ -124,7 +125,16 @@ class ServerManager {
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
private nonisolated func userDefaultsDidChange() {
|
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
|
/// Start the server with current configuration
|
||||||
|
|
@ -212,6 +222,12 @@ class ServerManager {
|
||||||
bunServer = server
|
bunServer = server
|
||||||
// Check server state to ensure it's actually running
|
// Check server state to ensure it's actually running
|
||||||
if server.getState() == .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
|
isRunning = true
|
||||||
lastError = nil
|
lastError = nil
|
||||||
// Reset crash counter on successful start
|
// Reset crash counter on successful start
|
||||||
|
|
@ -260,6 +276,9 @@ class ServerManager {
|
||||||
|
|
||||||
// Clear the auth token from SessionMonitor
|
// Clear the auth token from SessionMonitor
|
||||||
SessionMonitor.shared.setLocalAuthToken(nil)
|
SessionMonitor.shared.setLocalAuthToken(nil)
|
||||||
|
|
||||||
|
// Allow sleep when server is stopped
|
||||||
|
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||||
|
|
||||||
// Reset crash tracking when manually stopped
|
// Reset crash tracking when manually stopped
|
||||||
consecutiveCrashes = 0
|
consecutiveCrashes = 0
|
||||||
|
|
@ -377,6 +396,9 @@ class ServerManager {
|
||||||
// Update state immediately
|
// Update state immediately
|
||||||
isRunning = false
|
isRunning = false
|
||||||
bunServer = nil
|
bunServer = nil
|
||||||
|
|
||||||
|
// Allow sleep when server crashes
|
||||||
|
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||||
|
|
||||||
// Prevent multiple simultaneous crash handlers
|
// Prevent multiple simultaneous crash handlers
|
||||||
guard !isHandlingCrash else {
|
guard !isHandlingCrash else {
|
||||||
|
|
@ -489,6 +511,12 @@ class ServerManager {
|
||||||
/// Monitor server health periodically
|
/// Monitor server health periodically
|
||||||
func startHealthMonitoring() {
|
func startHealthMonitoring() {
|
||||||
Task {
|
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 {
|
while true {
|
||||||
try? await Task.sleep(for: .seconds(30))
|
try? await Task.sleep(for: .seconds(30))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ struct GeneralSettingsView: View {
|
||||||
private var showNotifications = true
|
private var showNotifications = true
|
||||||
@AppStorage("updateChannel")
|
@AppStorage("updateChannel")
|
||||||
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
||||||
|
@AppStorage("preventSleepWhenRunning")
|
||||||
|
private var preventSleepWhenRunning = true
|
||||||
|
|
||||||
@State private var isCheckingForUpdates = false
|
@State private var isCheckingForUpdates = false
|
||||||
|
|
||||||
|
|
@ -28,6 +30,14 @@ struct GeneralSettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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: {
|
} header: {
|
||||||
Text("Application")
|
Text("Application")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// VibeTunnel Version Configuration
|
// VibeTunnel Version Configuration
|
||||||
// This file contains the version and build number for the app
|
// This file contains the version and build number for the app
|
||||||
|
|
||||||
MARKETING_VERSION = 1.0.0-beta.5
|
MARKETING_VERSION = 1.0.0-beta.6
|
||||||
CURRENT_PROJECT_VERSION = 150
|
CURRENT_PROJECT_VERSION = 151
|
||||||
|
|
||||||
// Domain and GitHub configuration
|
// Domain and GitHub configuration
|
||||||
APP_DOMAIN = vibetunnel.sh
|
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 integration: Self
|
||||||
@Tag static var regression: Self
|
@Tag static var regression: Self
|
||||||
@Tag static var sessionManagement: Self
|
@Tag static var sessionManagement: Self
|
||||||
|
@Tag static var serverManager: Self
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@vibetunnel/vibetunnel-cli",
|
"name": "@vibetunnel/vibetunnel-cli",
|
||||||
"version": "1.0.0-beta.5",
|
"version": "1.0.0-beta.6",
|
||||||
"description": "Web frontend for terminal multiplexer",
|
"description": "Web frontend for terminal multiplexer",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue