Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
27 changes: 23 additions & 4 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5052,6 +5052,7 @@ struct CMUXCLI {
let title = optionValue(commandArgs, name: "--title") ?? "Notification"
let subtitle = optionValue(commandArgs, name: "--subtitle") ?? ""
let body = optionValue(commandArgs, name: "--body") ?? ""
let allowsReply = hasFlag(commandArgs, name: "--reply")
let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace")
let windowRaw = windowFromArgsOrOverride(commandArgs, windowOverride: windowId)
let windowHandle = try normalizeWindowHandle(windowRaw, client: client)
Expand All @@ -5078,12 +5079,25 @@ struct CMUXCLI {
targetSurface = try explicitSurfaceArg.map { try resolveSurfaceId($0, workspaceId: targetWorkspace, client: client) }
?? resolveSurfaceId(nil, workspaceId: targetWorkspace, client: client)
}
let payload = notificationPayload(title: title, subtitle: subtitle, body: body)
let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client)
print(response)
if allowsReply {
let payload = try client.sendV2(method: "notification.create_for_target", params: [
"workspace_id": targetWorkspace,
"surface_id": targetSurface,
"title": title,
"subtitle": subtitle,
"body": body,
"reply_shape": "text",
])
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
} else {
let payload = notificationPayload(title: title, subtitle: subtitle, body: body)
let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client)
print(response)
}
return
}
var params: [String: Any] = ["title": title, "subtitle": subtitle, "body": body]
if allowsReply { params["reply_shape"] = "text" }
let method: String
if explicitSurfaceArg != nil {
method = "notification.create"
Expand Down Expand Up @@ -16514,6 +16528,10 @@ struct CMUXCLI {
cmux send-key-panel --panel surface:2 ctrl+c
"""
case "notify":
let replyHelp = String(
localized: "cli.help.notify.reply",
defaultValue: "--reply Allow a free-text inline reply"
)
return """
Usage: cmux notify [flags]

Expand All @@ -16523,6 +16541,7 @@ struct CMUXCLI {
--title <text> Notification title (default: "Notification")
--subtitle <text> Notification subtitle
--body <text> Notification body
\(replyHelp)
--workspace <id|ref|index> Target workspace, except explicit surface UUIDs resolve globally
--surface <id|ref|index> Target surface (refs/indexes use workspace/window context)
--window <id|ref|index> Window context for workspace/surface refs and indexes
Expand Down Expand Up @@ -35206,7 +35225,7 @@ export default CMUXSessionRestore;
send-key [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <key>
send-panel --panel <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>] <text>
send-key-panel --panel <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>] <key>
notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]
notify --title <text> [--subtitle <text>] [--body <text>] [--reply] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]
list-notifications
dismiss-notification (--id <uuid> | --all-read)
mark-notification-read (--id <uuid> | --workspace <id|ref|index> [--surface <id|ref|index>] [--window <id|ref|index>] | --all)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
internal import CmuxMobileRPC
public import CmuxMobileShellModel
internal import Foundation
internal import OSLog

private let explicitTerminalInputLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios",
category: "mobile-shell"
)

extension MobileShellComposite {
/// Whether the Mac that owns `workspaceID` currently has an RPC channel able to accept terminal input.
///
/// This check does not change the selected Mac, workspace, terminal, or navigation state.
///
/// - Parameter workspaceID: The current UI row identifier for the target workspace.
/// - Returns: `true` when the workspace exists and its owning Mac has a live RPC client.
public func canSendTerminalInput(to workspaceID: MobileWorkspacePreview.ID) -> Bool {
guard workspaces.contains(where: { $0.id == workspaceID }) else { return false }
return workspaceMutationTarget(for: workspaceID).client != nil
}

/// Sends text to an explicit workspace and terminal without changing UI selection or navigation.
///
/// The workspace row selects the owning Mac's live RPC client, including a connected secondary Mac
/// in the aggregated workspace list, while the Mac-local workspace identifier is sent on the wire.
///
/// - Parameters:
/// - text: The exact terminal input bytes represented as text.
/// - workspaceID: The current UI row identifier for the target workspace.
/// - terminalID: The terminal that must still belong to `workspaceID`.
/// - Returns: `true` after the owning Mac acknowledges the input; otherwise `false`.
@discardableResult
public func sendTerminalInput(
_ text: String,
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID
) async -> Bool {
guard !text.isEmpty,
workspace(workspaceID, containsSurfaceID: terminalID.rawValue) else {
return false
}
let target = workspaceMutationTarget(for: workspaceID)
guard let client = target.client else { return false }

do {
_ = try await client.sendRequest(
MobileCoreRPCClient.requestData(
method: "terminal.input",
params: [
"workspace_id": remoteWorkspaceID(for: workspaceID).rawValue,
"surface_id": terminalID.rawValue,
"text": text,
"client_id": clientID,
]
)
)
return true
} catch {
explicitTerminalInputLog.error(
"explicit terminal input failed workspace=\(workspaceID.rawValue, privacy: .private) surface=\(terminalID.rawValue, privacy: .private) error=\(String(describing: error), privacy: .private)"
)
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import CmuxMobileShell
import CmuxMobileShellModel
import Foundation
import Observation
import OSLog
import UIKit
import UserNotifications

private let mobilePushLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios",
category: "push"
)

/// Bridges APNs push between the app-target `AppDelegate` and the mobile shell
/// store: drives opt-in registration, hands device tokens to the injected
/// ``CmuxAuthRuntime/PushRegistrationService``, and routes foreground
Expand Down Expand Up @@ -37,13 +43,17 @@ public final class MobilePushCoordinator {
private nonisolated(unsafe) let defaults: UserDefaults
private static let enabledKey = "cmux.notifications.pushEnabled"

/// APNs `aps.category` the web sets on every cmux terminal push (see
/// `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The matching
/// ``UNNotificationCategory`` registered below carries
/// Base APNs `aps.category` the web sets on non-replyable cmux terminal
/// pushes (see `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The
/// matching ``UNNotificationCategory`` registered below carries
/// `.customDismissAction`, so a swipe/clear delivers
/// `UNNotificationDismissActionIdentifier` to the app and we can forward the
/// dismiss to the Mac. Keep these two ids in sync.
public static let dismissSyncCategoryIdentifier = "cmux.terminal"
/// APNs category for terminal notifications that accept text input.
public static let replyCategoryIdentifier = "cmux.terminal.reply"
/// Notification action identifier delivered for a submitted inline reply.
public static let replyActionIdentifier = "cmux.reply"

@ObservationIgnored private weak var store: CMUXMobileShellStore?

Expand All @@ -68,6 +78,8 @@ public final class MobilePushCoordinator {
/// launch plus sign-in plus a slow attach.
private static let pendingDeeplinkLifetime: TimeInterval = 120
@ObservationIgnored private let now: () -> Date
@ObservationIgnored private var pendingReplyState = PendingReplyState()
@ObservationIgnored private var replySendInFlight = false

/// Creates a push coordinator.
/// - Parameters:
Expand All @@ -82,8 +94,8 @@ public final class MobilePushCoordinator {
/// - pendingDismissQueue: The durable phone→Mac dismiss outbox shared (via
/// `UserDefaults`) with the shell store, used when a swipe arrives before
/// any store exists. Defaults to the standard-defaults-backed queue.
/// - now: Clock seam for the pending-deeplink expiry. Defaults to
/// `Date.init`.
/// - now: Clock seam for pending deep-link and inline-reply expiry. Defaults
/// to `Date.init`.
public init(
registration: any PushRegistering,
analytics: any AnalyticsEmitting = NoopAnalytics(),
Expand All @@ -107,17 +119,23 @@ public final class MobilePushCoordinator {
public func bind(store: CMUXMobileShellStore) {
self.store = store
applyPendingDeeplinkIfReady()
Task { @MainActor [weak self] in
await self?.applyPendingReplyIfReady()
}
}

/// Re-apply a parked notification tap once its target can exist. Called by
/// the root view whenever the store's workspace list changes (the list is
/// empty until the Mac attach completes).
public func workspacesDidChange() {
applyPendingDeeplinkIfReady()
Task { @MainActor [weak self] in
await self?.applyPendingReplyIfReady()
}
}

/// Install the notification-center delegate, register the dismiss-sync
/// notification category, and, if already opted in, re-assert remote
/// Install the notification-center delegate, register the terminal
/// notification categories, and, if already opted in, re-assert remote
/// registration so a rotated token re-uploads. Call once at launch from the
/// AppDelegate.
public func configure(delegate: any UNUserNotificationCenterDelegate) {
Expand All @@ -132,7 +150,24 @@ public final class MobilePushCoordinator {
intentIdentifiers: [],
options: [.customDismissAction]
)
center.setNotificationCategories([dismissSyncCategory])
let replyAction = UNTextInputNotificationAction(
identifier: Self.replyActionIdentifier,
title: String(localized: "mobile.push.reply.action", defaultValue: "Reply", bundle: .module),
options: [],
textInputButtonTitle: String(localized: "mobile.push.reply.send", defaultValue: "Send", bundle: .module),
textInputPlaceholder: String(
localized: "mobile.push.reply.placeholder",
defaultValue: "Message the agent…",
bundle: .module
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let replyCategory = UNNotificationCategory(
identifier: Self.replyCategoryIdentifier,
actions: [replyAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
center.setNotificationCategories([dismissSyncCategory, replyCategory])
if isEnabled {
UIApplication.shared.registerForRemoteNotifications()
}
Expand Down Expand Up @@ -252,6 +287,35 @@ public final class MobilePushCoordinator {
applyPendingDeeplinkIfReady()
}

/// Parks an inline notification reply and sends it once its exact Mac, workspace, surface, and RPC channel are ready.
///
/// This path never changes the selected Mac, workspace, terminal, or navigation state.
/// - Parameters:
/// - text: The user's reply text, without the submit Return.
/// - workspaceId: The Mac-local workspace claim carried by the push.
/// - surfaceId: The exact terminal claim carried by the push.
/// - macDeviceId: The Mac that owns the claimed ids.
/// - retargetsToLiveSurfaceOwner: Whether a moved terminal may resolve in a
/// workspace other than the explicit claim.
public func handleReply(
text: String,
workspaceId: String?,
surfaceId: String?,
macDeviceId: String?,
retargetsToLiveSurfaceOwner: Bool
) async {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
pendingReplyState.park(PendingReply(
text: text,
workspaceId: workspaceId,
surfaceId: surfaceId,
macDeviceId: macDeviceId,
retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner,
createdAt: now()
))
await applyPendingReplyIfReady()
}

/// Apply the parked tap if its target can be navigated to right now;
/// otherwise keep it parked for the next ``bind(store:)`` or
/// ``workspacesDidChange()``.
Expand Down Expand Up @@ -348,6 +412,92 @@ public final class MobilePushCoordinator {
])
}

/// Applies the parked reply without mutating UI selection; later topology changes retry only unresolved prerequisites.
private func applyPendingReplyIfReady() async {
guard !replySendInFlight else { return }
let initialDecision = pendingReplyState.evaluate(
now: now(),
isStoreBound: store != nil,
isTargetReachable: false,
isChannelAvailable: false
)
switch initialDecision {
case .noPending:
return
case .expired:
mobilePushLog.info("dropping expired inline reply")
return
case .waiting:
break
case .ready:
return
}

guard let pending = pendingReplyState.pending, let store else { return }
guard let surfaceId = pending.surfaceId, !surfaceId.isEmpty else {
pendingReplyState.discard()
mobilePushLog.info("dropping inline reply without a surface id")
return
}

var workspaceTarget: MobileWorkspacePreview.ID
if let workspaceId = pending.workspaceId {
guard let resolved = store.workspaceID(
matchingRemoteWorkspaceID: workspaceId,
macDeviceID: pending.macDeviceId
) else { return }
workspaceTarget = resolved
} else if pending.retargetsToLiveSurfaceOwner {
guard let owner = store.workspaceID(
containingSurfaceID: surfaceId,
macDeviceID: pending.macDeviceId
) else { return }
workspaceTarget = owner
} else {
pendingReplyState.discard()
mobilePushLog.info("dropping confined inline reply without a workspace id")
return
}

if !store.workspace(workspaceTarget, containsSurfaceID: surfaceId) {
guard pending.retargetsToLiveSurfaceOwner,
let liveOwner = store.workspaceID(
containingSurfaceID: surfaceId,
macDeviceID: pending.macDeviceId
) else {
pendingReplyState.discard()
mobilePushLog.info("dropping inline reply because the target surface has no permitted live owner")
return
}
workspaceTarget = liveOwner
}

let decision = pendingReplyState.evaluate(
now: now(),
isStoreBound: true,
isTargetReachable: true,
isChannelAvailable: store.canSendTerminalInput(to: workspaceTarget)
)
guard case .ready(let ready) = decision else {
if case .expired = decision {
mobilePushLog.info("dropping expired inline reply")
}
return
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

replySendInFlight = true
let sent = await store.sendTerminalInput(
ready.text + "\r",
workspaceID: workspaceTarget,
terminalID: MobileTerminalPreview.ID(rawValue: surfaceId)
)
replySendInFlight = false
if !sent {
mobilePushLog.error("inline reply terminal input failed")
}
await applyPendingReplyIfReady()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// Forward a phone-side notification dismissal to the paired Mac so it marks
/// the notification read and clears its own banner. Fire-and-forget over the
/// attach channel; carries only the opaque notification id, never content.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

/// A single inline notification reply parked until its target Mac can accept input.
struct PendingReply: Equatable, Sendable {
let text: String
let workspaceId: String?
let surfaceId: String?
let macDeviceId: String?
let retargetsToLiveSurfaceOwner: Bool
let createdAt: Date
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// The pure lifecycle decision for a parked inline notification reply.
enum PendingReplyDecision: Equatable, Sendable {
case noPending
case waiting
case expired
case ready(PendingReply)
}
Loading