Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f06c69c
iOS: control Mac sleep & caffeinate from cmux mobile
Jun 26, 2026
bfacdfe
Refresh Swift file length budget for Mac power UI additions
Jun 26, 2026
03c06af
iOS: load Mac power status when capabilities arrive after view appears
Jun 26, 2026
2401f8c
Merge remote-tracking branch 'origin/main' into issue-6482-cmux-mobil…
Jun 26, 2026
7578512
CmuxMacPower: make pmset parsing a factory on MacKeepAwakeStatus
Jun 26, 2026
dee0eec
Merge remote-tracking branch 'origin/main' into issue-6482-cmux-mobil…
austinywang Jul 3, 2026
fa9d83e
Add mac power holder identity regression test
austinywang Jul 3, 2026
190367d
Fix mac power holder fallback identity
austinywang Jul 3, 2026
0749e86
Add pmset parser parentheses regression test
austinywang Jul 3, 2026
d70c726
Fix mac power command edge cases
austinywang Jul 3, 2026
85061f5
Report mac power status read failures
austinywang Jul 3, 2026
f1b3be0
Harden mobile mac power busy state
austinywang Jul 3, 2026
cdb4b44
Scope caffeinate disable to reported PIDs
austinywang Jul 4, 2026
15fdb0c
Merge remote-tracking branch 'origin/main' into issue-6482-cmux-mobil…
austinywang Jul 4, 2026
63e360b
Revalidate caffeinate PID before signaling
austinywang Jul 4, 2026
091de61
Satisfy Mac power package policy checks
austinywang Jul 4, 2026
d7117a6
Treat Mac sleep timeouts as failures
austinywang Jul 4, 2026
18c9e06
Tighten Mac power status and sleep errors
austinywang Jul 4, 2026
ef90e29
Address Mac power review comments
austinywang Jul 4, 2026
05de121
Avoid reused pid in Mac power timeout
austinywang Jul 4, 2026
a351d0a
Merge remote-tracking branch 'origin/main' into issue-6482-cmux-mobil…
austinywang Jul 4, 2026
bea8bc3
Refresh Swift length budget after base merge
austinywang Jul 4, 2026
2b8d8a6
Merge remote-tracking branch 'origin/main' into issue-6482-cmux-mobil…
austinywang Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
34499 CLI/cmux.swift
17954 Sources/AppDelegate.swift
16427 Sources/ContentView.swift
14270 Sources/TerminalController.swift
14339 Sources/TerminalController.swift
13172 Sources/Workspace.swift
12348 cmuxTests/AppDelegateShortcutRoutingTests.swift
12296 Sources/GhosttyTerminalView.swift
11669 Sources/Panels/BrowserPanel.swift
9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
8032 Sources/Panels/BrowserPanelView.swift
8016 CLI/cmux_open.swift
7743 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7747 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7405 cmuxTests/WorkspaceUnitTests.swift
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6359 cmuxTests/SessionPersistenceTests.swift
Expand All @@ -24,8 +24,8 @@
4483 Sources/cmuxApp.swift
4482 Sources/Panels/FilePreviewPanel.swift
4367 cmuxTests/BrowserPanelTests.swift
4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
4121 Sources/BrowserWindowPortal.swift
3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3934 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3896 cmuxTests/WindowAndDragTests.swift
Expand Down Expand Up @@ -70,7 +70,7 @@
1363 Sources/CMUXInstalledExtensionSidebarHostView.swift
1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift
1317 Sources/FileExplorerStore.swift
1295 cmuxTests/MobileHostAuthorizationTests.swift
1297 cmuxTests/MobileHostAuthorizationTests.swift
1291 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift
1290 cmuxTests/TextBoxSubmitActionTests.swift
1287 cmuxTests/RestorableAgentSessionIndexTests.swift
Expand Down Expand Up @@ -156,6 +156,7 @@
681 Sources/Panels/AgentSessionProcessStore.swift
680 Sources/FileExplorerSearchController.swift
677 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift
672 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift
672 cmuxTests/SessionPersistenceResumeBindingTests.swift
668 cmuxTests/FeedCoordinatorTests.swift
668 cmuxTests/SettingsWindowPresenterTests.swift
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/// One process keeping the Mac or its display awake, mirrored from the Mac's
/// `mac.power.status` result (`CmuxMacPower.MacPowerAssertionHolder`).
public struct MobileMacPowerHolder: Decodable, Sendable, Equatable, Identifiable {
/// The owning process id, or `0` when an older Mac host omits the field.
public let pid: Int

/// The owning process name, such as `caffeinate`, `cmux`, or a GUI app.
public let processName: String

/// The power assertion types this process currently holds.
public let assertionTypes: [String]

/// The optional assertion reason reported by the Mac host.
public let detail: String?

/// Stable per-row identity for SwiftUI lists.
public var id: String {
"\(pid)|\(processName)|\(assertionTypes.joined(separator: ","))|\(detail ?? "")"
}

private enum CodingKeys: String, CodingKey {
case pid
case processName = "process"
case assertionTypes = "types"
case detail
}

/// Decodes a holder from the Mac power-control wire payload.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pid = (try container.decodeIfPresent(Int.self, forKey: .pid)) ?? 0
processName = (try container.decodeIfPresent(String.self, forKey: .processName)) ?? ""
assertionTypes = (try container.decodeIfPresent([String].self, forKey: .assertionTypes)) ?? []
detail = try container.decodeIfPresent(String.self, forKey: .detail)
}

/// Creates a holder snapshot for tests and decoded Mac power status models.
public init(pid: Int, processName: String, assertionTypes: [String], detail: String?) {
self.pid = pid
self.processName = processName
self.assertionTypes = assertionTypes
self.detail = detail
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// Whether the connected Mac is currently being kept awake, and by whom.
///
/// Mirrors the Mac's `mac.power.status` result
/// (`CmuxMacPower.MacKeepAwakeStatus`). The booleans drive a localized summary
/// on the phone; `holders` backs the per-process detail rows.
public struct MobileMacPowerStatus: Decodable, Sendable, Equatable {
/// True when anything is preventing the Mac or its display from idle-sleeping.
public let keptAwake: Bool

/// True when a process holds a system idle or forced sleep-prevention assertion.
public let preventsSystemSleep: Bool

/// True when a process holds a display idle sleep-prevention assertion.
public let preventsDisplaySleep: Bool

/// True when cmux itself holds a keep-awake assertion.
public let cmuxKeepingAwake: Bool

/// True when a `caffeinate` process holds a keep-awake assertion.
public let caffeinateRunning: Bool

/// The processes currently holding keep-awake assertions.
public let holders: [MobileMacPowerHolder]

private enum CodingKeys: String, CodingKey {
case keptAwake = "kept_awake"
case preventsSystemSleep = "prevents_system_sleep"
case preventsDisplaySleep = "prevents_display_sleep"
case cmuxKeepingAwake = "cmux_keeping_awake"
case caffeinateRunning = "caffeinate_running"
case holders
}

/// Decodes a status snapshot from the Mac power-control wire payload.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
keptAwake = (try container.decodeIfPresent(Bool.self, forKey: .keptAwake)) ?? false
preventsSystemSleep = (try container.decodeIfPresent(Bool.self, forKey: .preventsSystemSleep)) ?? false
preventsDisplaySleep = (try container.decodeIfPresent(Bool.self, forKey: .preventsDisplaySleep)) ?? false
cmuxKeepingAwake = (try container.decodeIfPresent(Bool.self, forKey: .cmuxKeepingAwake)) ?? false
caffeinateRunning = (try container.decodeIfPresent(Bool.self, forKey: .caffeinateRunning)) ?? false
holders = (try container.decodeIfPresent([MobileMacPowerHolder].self, forKey: .holders)) ?? []
}

/// Creates a status snapshot for tests and local UI state.
public init(
keptAwake: Bool,
preventsSystemSleep: Bool,
preventsDisplaySleep: Bool,
cmuxKeepingAwake: Bool,
caffeinateRunning: Bool,
holders: [MobileMacPowerHolder]
) {
self.keptAwake = keptAwake
self.preventsSystemSleep = preventsSystemSleep
self.preventsDisplaySleep = preventsDisplaySleep
self.cmuxKeepingAwake = cmuxKeepingAwake
self.caffeinateRunning = caffeinateRunning
self.holders = holders
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
internal import CmuxMobileRPC

struct MobileMacSleepErrorClassifier {
func result(forSendError error: any Error) -> MobileMacSleepResult {
guard let connectionError = error as? MobileShellConnectionError else {
return .failed
}
switch connectionError {
case .rpcError:
return .refused
case .invalidResponse,
.connectionClosed,
.requestTimedOut,
.insecureManualRoute,
.attachTicketExpired,
.authorizationFailed,
.accountMismatch:
return .failed
}
}
}
Comment on lines +1 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Connection-close misclassified as failure after sleep request

The PR description states "A sleep request that drops the connection is treated as success (the Mac slept and the link died)", but MobileMacSleepErrorClassifier maps both .connectionClosed and .requestTimedOut to .failed. In practice tell application "System Events" to sleep causes osascript to return before the Mac actually sleeps, so the success response is usually delivered in time — but there is a real race window where the Mac can go to sleep before the TCP frame carrying the RPC response is flushed. When that race fires, the phone shows "Couldn't reach the Mac to sleep it." even though the Mac successfully went to sleep. .connectionClosed and .requestTimedOut arriving in direct response to a sleep command are the primary evidence of success, not failure, and should return .requested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am leaving this as .failed intentionally. The branch now has MobileMacSleepErrorClassifier tests asserting that connectionClosed and requestTimedOut are delivery failures, while only an explicit successful RPC response maps to .requested and an RPC error maps to .refused. A closed/timed-out transport after send is ambiguous from the phone's side; osascript normally returns before actual sleep, so treating those as success creates false positives. The older PR wording about dropped connections was stale after the sleep-error tightening commits.

— Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair correction — if osascript returns before the actual sleep, the response window is reliable enough that a transport failure genuinely is ambiguous (dropped wifi, flaky link, etc.) rather than a sleep signal. The test-backed decision to require an explicit success response makes sense.

Withdrawing the concern.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// The outcome of asking the Mac to sleep.
///
/// Only an acknowledged request is ``requested``. Timeouts, connection closes,
/// and other delivery failures are reported as ``failed``.
public enum MobileMacSleepResult: Sendable, Equatable {
/// The Mac acknowledged the request.
case requested

/// The Mac explicitly refused, most often because Automation access is missing.
case refused

/// The request could not be delivered because the Mac was unreachable or auth failed.
case failed
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
internal import CmuxMobileRPC
internal import Foundation
internal import OSLog

nonisolated private let macPowerLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios",
category: "mobile-mac-power"
)

/// Wrapper for the `mac.power.keep_awake.disable` result `{ terminated_caffeinate, status }`.
private struct MobileMacKeepAwakeDisableResponse: Decodable {
let status: MobileMacPowerStatus

private enum CodingKeys: String, CodingKey {
case status
}
}

// MARK: - Mac power control RPCs

extension MobileShellComposite {
/// Read the connected Mac's keep-awake status. Returns `nil` if the Mac is
/// unreachable or the status could not be decoded.
public func macPowerStatus(macDeviceID: String? = nil) async -> MobileMacPowerStatus? {
guard let client = macPowerClient(for: macDeviceID) else { return nil }
do {
let request = try MobileCoreRPCClient.requestData(
method: "mac.power.status",
params: ["client_id": clientID]
)
let data = try await client.sendRequest(request)
return try? JSONDecoder().decode(MobileMacPowerStatus.self, from: data)
} catch {
_ = disconnectForAuthorizationFailureIfNeeded(error)
macPowerLog.error("mac.power.status failed error=\(String(describing: error), privacy: .public)")
return nil
}
}

/// Put the connected Mac to sleep.
public func sleepMac(macDeviceID: String? = nil) async -> MobileMacSleepResult {
guard let client = macPowerClient(for: macDeviceID) else { return .failed }
let request: Data
do {
request = try MobileCoreRPCClient.requestData(
method: "mac.power.sleep",
params: ["client_id": clientID]
)
} catch {
macPowerLog.error("mac.power.sleep request build failed error=\(String(describing: error), privacy: .public)")
return .failed
}
do {
_ = try await client.sendRequest(request)
return .requested
} catch {
if disconnectForAuthorizationFailureIfNeeded(error) { return .failed }
let result = MobileMacSleepErrorClassifier().result(forSendError: error)
switch result {
case .requested:
break
case .refused:
// The Mac answered with an explicit error (e.g. Automation access
// not granted yet): a genuine refusal, the Mac did not sleep.
break
case .failed:
macPowerLog.error("mac.power.sleep send failed error=\(String(describing: error), privacy: .public)")
}
return result
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Disable active keep-awake (terminate caffeinate) on the connected Mac, and
/// return the fresh status so the caller can reflect whatever still holds it
/// awake. Returns `nil` if the Mac is unreachable.
public func disableMacKeepAwake(macDeviceID: String? = nil) async -> MobileMacPowerStatus? {
guard let client = macPowerClient(for: macDeviceID) else { return nil }
do {
let request = try MobileCoreRPCClient.requestData(
method: "mac.power.keep_awake.disable",
params: ["client_id": clientID]
)
let data = try await client.sendRequest(request)
return try? JSONDecoder().decode(MobileMacKeepAwakeDisableResponse.self, from: data).status
} catch {
_ = disconnectForAuthorizationFailureIfNeeded(error)
macPowerLog.error("mac.power.keep_awake.disable failed error=\(String(describing: error), privacy: .public)")
return nil
}
}

/// Resolve the RPC client that owns `macDeviceID`. Power commands target the
/// Mac the phone is actually connected to; an empty/absent id uses the
/// foreground connection (mirrors the workspace/notification routing).
private func macPowerClient(for macDeviceID: String?) -> MobileCoreRPCClient? {
guard let macDeviceID, !macDeviceID.isEmpty else { return remoteClient }
if foregroundMacDeviceID == macDeviceID { return remoteClient }
return secondaryMacSubscriptions[macDeviceID]?.client
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private static let workspaceCloseCapability = "workspace.close.v1"
private static let dogfoodFeedbackCapability = "dogfood.v1"
private static let workspaceGroupsCapability = "workspace.groups.v1"
private static let macPowerControlCapability = "mac.power.control.v1"
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000

/// How long the render-grid stream may stay silent (no event of any topic)
Expand Down Expand Up @@ -311,6 +312,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) }
/// Whether the Mac supports dogfood feedback submission.
public var supportsDogfoodFeedback: Bool { supportedHostCapabilities.contains(Self.dogfoodFeedbackCapability) }
/// Whether the Mac supports the Mac power controls (sleep / disable
/// keep-awake / keep-awake status) exposed in the per-computer detail view.
public var supportsMacPowerControl: Bool { supportedHostCapabilities.contains(Self.macPowerControlCapability) }
/// Bumped whenever the applied terminal theme actually changes (a connect
/// that reports a different theme than the one currently in
/// ``TerminalThemeStore``). The mounted terminal representable observes this
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import Testing
@testable import CmuxMobileShell

@Suite struct MobileMacPowerHolderTests {
@Test func decodedHoldersWithoutPidsKeepDistinctIdentities() throws {
let data = Data("""
[
{
"process": "caffeinate",
"types": ["PreventUserIdleSystemSleep"],
"detail": "assertion one"
},
{
"process": "backupd",
"types": ["PreventSystemSleep"],
"detail": "assertion two"
}
]
""".utf8)

let holders = try JSONDecoder().decode([MobileMacPowerHolder].self, from: data)

#expect(holders.map(\.pid) == [0, 0])
#expect(Set(holders.map(\.id)).count == holders.count)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import CmuxMobileRPC
import Testing

@testable import CmuxMobileShell

@Suite struct MobileMacSleepResultTests {
@Test func explicitRPCErrorMapsToRefused() {
let classifier = MobileMacSleepErrorClassifier()
#expect(classifier.result(forSendError: MobileShellConnectionError.rpcError("sleep_failed", "nope")) == .refused)
}

@Test func timeoutAndDeliveryFailuresMapToFailed() {
let classifier = MobileMacSleepErrorClassifier()
let failures: [MobileShellConnectionError] = [
.requestTimedOut,
.connectionClosed,
.invalidResponse,
.insecureManualRoute,
.attachTicketExpired,
.authorizationFailed("auth"),
.accountMismatch("account"),
]
for failure in failures {
#expect(classifier.result(forSendError: failure) == .failed)
}
}
}
Loading
Loading