diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index fc394654a25..e03686612f9 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -239,8 +239,10 @@ extension RemoteTmuxControlConnection { // (see ``PostAttachAction``). switch pendingPostAttachAction { case .reseed: + pushMirrorSessionEnvironment() reseedAfterReconnect() case .applyClientSize: + pushMirrorSessionEnvironment() // A surface that hasn't computed a grid yet is covered by the // debounced `setClientSize` instead. if let size = lastClientSize { diff --git a/Sources/RemoteTmuxControlConnection+Commands.swift b/Sources/RemoteTmuxControlConnection+Commands.swift index f9238e2e5a9..cd4af61ad56 100644 --- a/Sources/RemoteTmuxControlConnection+Commands.swift +++ b/Sources/RemoteTmuxControlConnection+Commands.swift @@ -12,6 +12,62 @@ extension RemoteTmuxControlConnection { sendInternal(command, kind: .other) } + // MARK: - Mirror session environment (issue #833) + + /// Marker signalling to remote shell integration that this tmux session is + /// mirrored by a local cmux over ssh-tmux (`tmux -CC`, no relay socket). + static let mirrorMarkerEnvironmentKey = "CMUX_REMOTE_TMUX_MIRROR" + + /// Pushes the mirror marker + identity pairs into the remote tmux SESSION + /// environment. Called from the first post-attach `list-windows` result, so + /// both paths — first connect and every reconnect — refresh values that + /// would otherwise go permanently stale after an app relaunch (issue #833: + /// the `tmux -CC` attach has no cmux wrapper shell outside tmux on the + /// remote, so nobody else ever re-publishes them). + /// + /// Deliberately NOT pushed: `CMUX_SOCKET_PATH`. The ssh-tmux transport has + /// no relay/reverse forward, so the local Mac socket path is meaningless on + /// the remote host; publishing it would point remote `cmux` CLI invocations + /// at a dead socket. Notification delivery instead rides the OSC 777/9 + /// intercept in ``RemoteTmuxSessionMirror`` (see + /// ``RemoteTmuxNotificationOSCFilter``). + /// + /// Session scope (`-t`, not `-g`): the shell-integration refresh path runs + /// a session-scoped `show-environment`, which does not surface `-g` values. + func pushMirrorSessionEnvironment() { + // Target by the stable session id when known so the push can't race a + // rename (same convention as `rename-session`). + guard let target = sessionId.map({ "$\($0)" }) + ?? RemoteTmuxHost.controlModeLineSafeName(sessionName) + .map(RemoteTmuxHost.shellSingleQuoted) + else { return } + var pairs = mirrorEnvironment + pairs[Self.mirrorMarkerEnvironmentKey] = "1" + let commands = Self.mirrorEnvironmentCommands(target: target, pairs: pairs) + guard !commands.isEmpty else { return } + _ = sendBatchInternal(commands, kinds: commands.map { _ in .other }) + } + + /// Builds the `set-environment -t KEY VALUE` command lines for a + /// push, dropping any pair that could break the line-oriented control + /// stream. Pure (and deterministic — sorted by key) so tests can pin the + /// exact wire format. + static func mirrorEnvironmentCommands( + target: String, + pairs: [String: String] + ) -> [String] { + pairs.sorted { $0.key < $1.key }.compactMap { key, value in + // Keys are ours (static identifiers), but guard anyway: one CR/LF + // would terminate the command line before tmux parses the quotes. + guard RemoteTmuxHost.controlModeLineSafeName(key) != nil, + !key.contains(" "), + RemoteTmuxHost.controlModeLineSafeName(value) != nil + else { return nil } + return "set-environment -t \(target) \(key) " + + RemoteTmuxHost.shellSingleQuoted(value) + } + } + /// Sends a command and reports how its `%begin`/`%end` block resolved: /// `true` on `%end`, `false` on `%error` — or `false` if the stream resets /// before the block arrives, since a fresh control stream can never answer diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 504b0497773..69cff01ef4f 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -195,6 +195,25 @@ final class RemoteTmuxControlConnection { var lastSizingSendAt: ContinuousClock.Instant? var pendingPostAttachAction: PostAttachAction? + /// Session-scoped environment pairs identifying the local mirror, pushed to + /// the remote tmux session on every attach AND reconnect (issue #833). + /// Session scope (`set-environment -t`) is deliberate: the shell-integration + /// pull path runs a session-scoped `show-environment`, which does not see + /// global (`-g`) values. Set by the controller when the mirror workspace is + /// created; empty until then (and for non-mirror consumers). + private(set) var mirrorEnvironment: [String: String] = [:] + + /// Replaces the identity pairs pushed by ``pushMirrorSessionEnvironment()``. + /// A connection that already passed its post-attach point (a reused, + /// still-connected connection) pushes the fresh pairs immediately; + /// otherwise the pending post-attach drain pushes them. + func setMirrorEnvironment(_ pairs: [String: String]) { + mirrorEnvironment = pairs + if connectionState == .connected, attachBlockDrained, pendingPostAttachAction == nil { + pushMirrorSessionEnvironment() + } + } + /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the /// rendered grid oscillate (e.g. cols 154→155→156→161→…, ~15 distinct grids in /// ~1.3s), and each previously sent its own `refresh-client -C` → ~15 SIGWINCH / diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 05c4cc2e808..4952b7b36ac 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -307,6 +307,16 @@ final class RemoteTmuxController { applyCreationTitleAsCustomTitle: false ) workspace.isRemoteTmuxMirror = true + // Identity pairs the connection pushes into the remote SESSION + // environment on attach and every reconnect (issue #833). Workspace id + // is published under both keys, matching the SSH-workspace bootstrap + // convention (`CMUX_TAB_ID` is the legacy alias). No socket path: the + // ssh-tmux transport has no relay, so a local path would be dead on the + // remote — see ``RemoteTmuxControlConnection/pushMirrorSessionEnvironment()``. + connection.setMirrorEnvironment([ + "CMUX_WORKSPACE_ID": workspace.id.uuidString, + "CMUX_TAB_ID": workspace.id.uuidString, + ]) workspace.remoteTmuxWindowOrderSync = { [weak self, weak workspace] orderedPanelIds, verification in guard let self, let workspace else { return false } return self.handleMirrorWindowsReordered( diff --git a/Sources/RemoteTmuxNotificationOSCFilter.swift b/Sources/RemoteTmuxNotificationOSCFilter.swift new file mode 100644 index 00000000000..360c0b4a651 --- /dev/null +++ b/Sources/RemoteTmuxNotificationOSCFilter.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Intercepts OSC desktop-notification escapes in a mirrored pane's output +/// stream (issue #833). +/// +/// A remote process inside an ssh-tmux mirror emits notifications with the +/// xterm/urxvt OSC sequences: +/// - `ESC ] 777;notify;;<body> BEL|ST` (rxvt-unicode / wezterm style) +/// - `ESC ] 9;<body> BEL|ST` (iTerm2 growl style; no title) +/// +/// `%output` is the raw pty copy, so tmux forwards these bytes verbatim to the +/// mirror. The mirror's Ghostty surface would parse them, but its +/// `desktop_notification` callback attributes by the surface's local process +/// TTY — which a manual-mirror surface does not have — and mirror workspaces +/// are excluded from agent TTY delivery (`AgentDeliveryTargetResolution`). So +/// the mirror layer intercepts the sequence here, strips it from the stream, +/// and reports `(title, body)` so the session mirror can attribute the +/// notification to the pane's surface + workspace itself. +/// +/// Any other OSC sequence (titles, hyperlinks, clipboard, non-`notify` +/// OSC 777 subcommands) passes through byte-identical. +/// +/// Stateful across calls: a `%output` chunk can split the sequence at any +/// byte. While a sequence still *might* be a notification its bytes are +/// buffered; the moment the payload diverges from both notification prefixes +/// the buffer is flushed verbatim and the rest of the sequence streams +/// through unbuffered. An unfinished candidate that exceeds +/// ``maxBufferedBytes`` is flushed verbatim too, so a hostile or corrupt +/// stream can never pin memory or swallow output. +struct RemoteTmuxNotificationOSCFilter { + private enum State { + case text // normal passthrough + case esc // saw ESC, holding it until we know if it's `ESC ]` + case collect // inside an OSC that may still be a notification; buffering + case collectEsc // in collect, saw ESC; maybe the `ESC \` terminator + case passOsc // inside a non-notification OSC; streaming through + case passOscEsc // in passOsc, saw ESC; maybe the `ESC \` terminator + } + + /// Ceiling on bytes buffered for an unfinished candidate sequence. An + /// overflowing sequence is passed through verbatim instead of stripped. + static let maxBufferedBytes = 4096 + + private static let notifyPrefix = Array("777;notify;".utf8) + private static let osc9Prefix = Array("9;".utf8) + + private var state: State = .text + /// Original bytes of the candidate sequence (`ESC ]` + payload so far), + /// replayed verbatim when the sequence turns out not to be a notification. + private var raw: [UInt8] = [] + /// Payload bytes only (after `ESC ]`), matched against the prefixes and + /// decoded into `(title, body)` on a hit. + private var payload: [UInt8] = [] + + /// Creates a filter with no buffered escape-sequence state. + init() {} + + /// Returns `data` with any complete notification sequences removed, + /// invoking `onNotification(title, body)` once per hit in stream order. + /// OSC 9 hits report an empty title. + mutating func filter( + _ data: Data, + onNotification: (_ title: String, _ body: String) -> Void + ) -> Data { + // Hot path: not mid-sequence and no ESC in the chunk — nothing to do. + if state == .text, !data.contains(0x1b) { return data } + var out = [UInt8]() + out.reserveCapacity(data.count) + for byte in data { + switch state { + case .text: + if byte == 0x1b { + state = .esc // hold the ESC; emit it only if it isn't `ESC ]` + } else { + out.append(byte) + } + case .esc: + if byte == UInt8(ascii: "]") { + raw = [0x1b, byte] + payload.removeAll(keepingCapacity: true) + state = .collect + } else if byte == 0x1b { + out.append(0x1b) // emit the held ESC, keep holding the new one + } else { + out.append(0x1b) + out.append(byte) + state = .text + } + case .collect: + if byte == 0x07 { // BEL terminator + finishCandidate(terminator: [0x07], into: &out, onNotification) + } else if byte == 0x1b { + state = .collectEsc + } else { + raw.append(byte) + payload.append(byte) + reclassifyCandidate(into: &out) + } + case .collectEsc: + if byte == 0x5c { // `ESC \` (ST) terminator + finishCandidate(terminator: [0x1b, 0x5c], into: &out, onNotification) + } else { + // An OSC payload cannot legally contain a bare ESC; treat the + // sequence as malformed and pass everything through verbatim. + out.append(contentsOf: raw) + out.append(0x1b) + raw.removeAll(keepingCapacity: true) + payload.removeAll(keepingCapacity: true) + if byte == 0x1b { + state = .esc // the new ESC may start a fresh sequence + } else { + out.append(byte) + state = .text + } + } + case .passOsc: + if byte == 0x07 { + out.append(byte) + state = .text + } else if byte == 0x1b { + state = .passOscEsc + } else { + out.append(byte) + } + case .passOscEsc: + if byte == 0x5c { + out.append(0x1b) + out.append(0x5c) + state = .text + } else if byte == 0x1b { + out.append(0x1b) // emit the held ESC, keep holding the new one + } else { + out.append(0x1b) + out.append(byte) + state = .passOsc + } + } + } + return Data(out) + } + + /// While collecting, checks the payload against both notification prefixes + /// and the buffer ceiling; a sequence that can no longer be a notification + /// (or grew too large unfinished) is flushed verbatim and the remainder + /// streams through as a plain OSC. + private mutating func reclassifyCandidate(into out: inout [UInt8]) { + if raw.count <= Self.maxBufferedBytes, + Self.couldMatch(payload, prefix: Self.notifyPrefix) + || Self.couldMatch(payload, prefix: Self.osc9Prefix) { + return + } + out.append(contentsOf: raw) + raw.removeAll(keepingCapacity: true) + payload.removeAll(keepingCapacity: true) + state = .passOsc + } + + /// Whether `payload` is still compatible with `prefix` (either side is a + /// prefix of the other). + private static func couldMatch(_ payload: [UInt8], prefix: [UInt8]) -> Bool { + payload.count >= prefix.count + ? payload.starts(with: prefix) + : prefix.starts(with: payload) + } + + /// A candidate sequence reached its terminator: strip + report a + /// notification hit, or replay the original bytes for anything else + /// (including a too-short candidate like `ESC ] 9 BEL`). + private mutating func finishCandidate( + terminator: [UInt8], + into out: inout [UInt8], + _ onNotification: (_ title: String, _ body: String) -> Void + ) { + defer { + raw.removeAll(keepingCapacity: true) + payload.removeAll(keepingCapacity: true) + state = .text + } + if payload.starts(with: Self.notifyPrefix) { + let rest = payload.dropFirst(Self.notifyPrefix.count) + if let separator = rest.firstIndex(of: UInt8(ascii: ";")) { + onNotification( + Self.decode(rest[..<separator]), + Self.decode(rest[rest.index(after: separator)...]) + ) + } else { + // `777;notify;<title>` without a body separator: title only. + onNotification(Self.decode(rest), "") + } + } else if payload.starts(with: Self.osc9Prefix) { + onNotification("", Self.decode(payload.dropFirst(Self.osc9Prefix.count))) + } else { + out.append(contentsOf: raw) + out.append(contentsOf: terminator) + } + } + + private static func decode(_ bytes: ArraySlice<UInt8>) -> String { + String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Sources/RemoteTmuxSessionMirror+OutputRouting.swift b/Sources/RemoteTmuxSessionMirror+OutputRouting.swift index 3a5721e623b..0e70541c0ff 100644 --- a/Sources/RemoteTmuxSessionMirror+OutputRouting.swift +++ b/Sources/RemoteTmuxSessionMirror+OutputRouting.swift @@ -4,28 +4,57 @@ import Foundation @MainActor extension RemoteTmuxSessionMirror { + /// Bounded handoff shared by every mirror, mirroring how the Ghostty OSC + /// callback funnels into ``GhosttyDesktopNotificationIngress`` (same + /// delivery, retargeting, and flood policy). + static let paneNotificationIngress = GhosttyDesktopNotificationIngress() + func routeOutput(paneId: Int, data: Data) { // Strip the screen/tmux `ESC k <title> ST` window-title escape that a remote // shell (TERM=screen*/tmux*) emits. Per-pane state survives chunk splits. var filter = titleFilters[paneId] ?? RemoteTmuxScreenTitleFilter() let cleaned = filter.filter(data) titleFilters[paneId] = filter - routeOrQueueCleanedOutput(paneId: paneId, data: cleaned) + // Intercept OSC 777/9 desktop-notification escapes (issue #833): the + // mirror surface has no local process TTY to attribute them by, so the + // mirror strips the sequence and delivers the notification itself, + // attributed to this pane's surface + workspace. + var notificationFilter = notificationFilters[paneId] ?? RemoteTmuxNotificationOSCFilter() + let denotified = notificationFilter.filter(cleaned) { [weak self] title, body in + self?.deliverPaneNotification(paneId: paneId, title: title, body: body) + } + notificationFilters[paneId] = notificationFilter + routeOrQueueCleanedOutput(paneId: paneId, data: denotified) } /// Applies an authoritative snapshot independently from the logical live /// escape stream, then catches that stream up across the capture boundary. func routeSeed(paneId: Int, seed: RemoteTmuxPaneSeed) { var liveFilter = titleFilters[paneId] ?? RemoteTmuxScreenTitleFilter() - for data in seed.discardedOutput { _ = liveFilter.filter(data) } + var liveNotificationFilter = notificationFilters[paneId] + ?? RemoteTmuxNotificationOSCFilter() + // Seed bytes replay history (or bytes the snapshot already covers), so a + // notification escape found here is stripped but NOT delivered — a + // reconnect's full-history reseed must not re-fire old notifications. + let suppressed: (String, String) -> Void = { _, _ in } + for data in seed.discardedOutput { + _ = liveNotificationFilter.filter(liveFilter.filter(data), onNotification: suppressed) + } var snapshotFilter = RemoteTmuxScreenTitleFilter() - var renderedBytes = snapshotFilter.filter(seed.snapshot) + var snapshotNotificationFilter = RemoteTmuxNotificationOSCFilter() + var renderedBytes = snapshotNotificationFilter.filter( + snapshotFilter.filter(seed.snapshot), + onNotification: suppressed + ) renderedBytes.append(seed.state) for data in seed.catchUpOutput { - renderedBytes.append(liveFilter.filter(data)) + renderedBytes.append( + liveNotificationFilter.filter(liveFilter.filter(data), onNotification: suppressed) + ) } titleFilters[paneId] = liveFilter + notificationFilters[paneId] = liveNotificationFilter guard let target = authoritativeGrid(forPane: paneId) else { if seed.kind == .fullHistory { deferredFullPaneReseeds.remove(paneId) } @@ -193,6 +222,26 @@ extension RemoteTmuxSessionMirror { mirror.routeOutput(paneId: paneId, data: data) } + /// Delivers an intercepted OSC 777/9 notification through the same bounded + /// ingress the Ghostty desktop-notification callback uses, attributed to the + /// pane's mirror surface and workspace (issue #833). An empty title falls + /// back to the workspace title downstream (`TerminalNotificationStore`). + func deliverPaneNotification(paneId: Int, title: String, body: String) { + guard !(title.isEmpty && body.isEmpty), + let workspaceId = mirroredWorkspaceId else { return } + let surfaceId = windowIdContaining(pane: paneId) + .flatMap { windowMirrorByWindowId[$0]?.panel(forPane: paneId)?.id } + Self.paneNotificationIngress.submit(GhosttyDesktopNotificationRequest( + tabId: workspaceId, + surfaceId: surfaceId, + // No hook directory: the emitting process runs on the remote host, + // so a local project-hook lookup would resolve the wrong config. + hookDirectory: nil, + title: title, + body: body + )) + } + private func authoritativeGrid(forPane paneId: Int) -> (columns: Int, rows: Int)? { guard let windowId = windowIdContaining(pane: paneId), let window = connection.windowsByID[windowId] else { return nil } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index f0d8370e45c..218699a7be4 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -119,6 +119,10 @@ final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner { /// Per-pane filter that strips the screen/tmux `ESC k <title> ST` window-title /// escape from `%output` (stateful across chunk boundaries). var titleFilters: [Int: RemoteTmuxScreenTitleFilter] = [:] + /// Per-pane filter that intercepts OSC 777/9 desktop-notification escapes + /// from `%output` (stateful across chunk boundaries) so a remote process + /// inside the mirrored session can notify locally (issue #833). + var notificationFilters: [Int: RemoteTmuxNotificationOSCFilter] = [:] /// Authoritative seed bytes waiting for Ghostty's terminal grid to consume /// the pane's published dimensions. Surface sizing APIs expose the requested /// grid before Ghostty's I/O thread applies it, so seed delivery cannot use @@ -237,6 +241,7 @@ final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner { // arrives while not connected). if state != .connected { self?.titleFilters.removeAll() + self?.notificationFilters.removeAll() self?.clearPendingPaneSeedDeliveries() self?.windowMirrorByWindowId.values.forEach { $0.cancelPendingControlPaneFocus() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 26d19cd278c..8ec00186f3b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1664,6 +1664,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 0D17C0DE0D17C0DE0D17C002 /* RemoteTmuxConnectionObservers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */; }; 6A9D9021221BF8B429986368 /* RemoteTmuxConnectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18D24AB9D3168ABACBCF8FE /* RemoteTmuxConnectionState.swift */; }; D4A72DFB57B2B6043CF2C6DC /* RemoteTmuxConnectionWindowSizingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */; }; + 94A9782A9AA0C554D2CCAA9A /* RemoteTmuxMirrorEnvironmentPushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180099E8D881C58A4546527D /* RemoteTmuxMirrorEnvironmentPushTests.swift */; }; + 3ABD5AD438AF5EDB33109C3A /* RemoteTmuxNotificationOSCFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13FF2DD7517912EADFBE40C8 /* RemoteTmuxNotificationOSCFilterTests.swift */; }; 477796B44AAC7290AA9EFB6C /* RemoteTmuxControlCommandKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */; }; 7B577C74BB011F20214F880E /* RemoteTmuxControlConnection+CommandResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */; }; D77330020000000000000002 /* RemoteTmuxControlConnection+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77330020000000000000001 /* RemoteTmuxControlConnection+Commands.swift */; }; @@ -2186,6 +2188,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */; }; 7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */; }; 7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */; }; + 03AB2324CB0E78800289A8EE /* RemoteTmuxNotificationOSCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CA7060A6D642DB5ED9DCF9 /* RemoteTmuxNotificationOSCFilter.swift */; }; F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; @@ -4418,6 +4421,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionObservers.swift; sourceTree = "<group>"; }; A18D24AB9D3168ABACBCF8FE /* RemoteTmuxConnectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionState.swift; sourceTree = "<group>"; }; 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionWindowSizingTests.swift; sourceTree = "<group>"; }; + 180099E8D881C58A4546527D /* RemoteTmuxMirrorEnvironmentPushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorEnvironmentPushTests.swift; sourceTree = "<group>"; }; + 13FF2DD7517912EADFBE40C8 /* RemoteTmuxNotificationOSCFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNotificationOSCFilterTests.swift; sourceTree = "<group>"; }; AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlCommandKind.swift; sourceTree = "<group>"; }; 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+CommandResults.swift"; sourceTree = "<group>"; }; D77330020000000000000001 /* RemoteTmuxControlConnection+Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+Commands.swift"; sourceTree = "<group>"; }; @@ -4930,6 +4935,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeExitedAgentLivenessTests.swift; sourceTree = "<group>"; }; 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeLaunchFlavor.swift; sourceTree = "<group>"; }; 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRemoteContext.swift; sourceTree = "<group>"; }; + 18CA7060A6D642DB5ED9DCF9 /* RemoteTmuxNotificationOSCFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNotificationOSCFilter.swift; sourceTree = "<group>"; }; F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRunPromptBatch.swift; sourceTree = "<group>"; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; }; C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = "<group>"; }; @@ -7259,6 +7265,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */, 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */, 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */, + 18CA7060A6D642DB5ED9DCF9 /* RemoteTmuxNotificationOSCFilter.swift */, A74770010000000000000006 /* SessionConfigFrameEntry.swift */, A74770010000000000000008 /* SessionConfigFrameRing.swift */, A74770010000000000000004 /* SessionDisplaySnapshot.swift */, @@ -8179,6 +8186,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 74060000000000000000000D /* RemoteTmuxWindowMirrorFocusSeedTests.swift */, 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */, 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */, + 180099E8D881C58A4546527D /* RemoteTmuxMirrorEnvironmentPushTests.swift */, + 13FF2DD7517912EADFBE40C8 /* RemoteTmuxNotificationOSCFilterTests.swift */, 7990B0017990B0017990B001 /* RemoteTmuxPaneSeedTransportTests.swift */, B1B1B1B1B1B1B1B1B1B10001 /* RemoteTmuxMirrorNewPaneKeyFocusTests.swift */, B2B2B2B2B2B2B2B2B2B20002 /* RemoteTmuxMirrorPaneInputMappingTests.swift */, @@ -10214,6 +10223,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */, 7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */, 7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */, + 03AB2324CB0E78800289A8EE /* RemoteTmuxNotificationOSCFilter.swift in Sources */, F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */, @@ -11302,6 +11312,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8E2A4C1D5F3096871A2B4A1 /* RemoteTmuxBonsplitImpositionRenderTests.swift in Sources */, 5F5553CA5553CA5553CA0001 /* RemoteTmuxCapabilitiesTests.swift in Sources */, D4A72DFB57B2B6043CF2C6DC /* RemoteTmuxConnectionWindowSizingTests.swift in Sources */, + 94A9782A9AA0C554D2CCAA9A /* RemoteTmuxMirrorEnvironmentPushTests.swift in Sources */, + 3ABD5AD438AF5EDB33109C3A /* RemoteTmuxNotificationOSCFilterTests.swift in Sources */, A02D147033ACB35CF35A5F35 /* RemoteTmuxControlParserBlankLineTests.swift in Sources */, B87A510D6D6000FCCBFEA02C /* RemoteTmuxControlParserLayoutTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift b/cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift new file mode 100644 index 00000000000..fd4fee41d34 --- /dev/null +++ b/cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift @@ -0,0 +1,236 @@ +import AppKit +import CmuxRemoteSession +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior tests for the mirror-identity environment push (issue #833). +/// +/// An ssh-tmux mirror attaches with `tmux -CC` directly — there is no cmux +/// wrapper shell outside tmux on the remote host, so nothing ever re-publishes +/// cmux identity into the tmux environment after an app relaunch. The +/// connection now pushes a marker + identity pairs itself, on first connect +/// AND on every reconnect, using SESSION scope (`set-environment -t`) because +/// the shell-integration refresh path runs a session-scoped +/// `show-environment` that cannot see `-g` values. +@MainActor +@Suite struct RemoteTmuxMirrorEnvironmentPushTests { + + // MARK: - Pure command construction + + @Test func buildsSessionScopedSortedQuotedCommands() { + let commands = RemoteTmuxControlConnection.mirrorEnvironmentCommands( + target: "'work'", + pairs: [ + "CMUX_WORKSPACE_ID": "ABC-123", + "CMUX_REMOTE_TMUX_MIRROR": "1", + ] + ) + #expect(commands == [ + "set-environment -t 'work' CMUX_REMOTE_TMUX_MIRROR '1'", + "set-environment -t 'work' CMUX_WORKSPACE_ID 'ABC-123'", + ]) + } + + @Test func valueSingleQuotingEscapesEmbeddedQuote() { + let commands = RemoteTmuxControlConnection.mirrorEnvironmentCommands( + target: "$5", + pairs: ["K": "it's"] + ) + #expect(commands == ["set-environment -t $5 K 'it'\\''s'"]) + } + + @Test func dropsPairsThatWouldBreakTheControlLine() { + // CR/LF or control bytes would terminate the command line before tmux + // parses the quotes; a spaced key would splice into extra arguments. + let commands = RemoteTmuxControlConnection.mirrorEnvironmentCommands( + target: "'s'", + pairs: [ + "GOOD": "value", + "BAD_VALUE": "line1\nline2", + "BAD KEY": "x", + ] + ) + #expect(commands == ["set-environment -t 's' GOOD 'value'"]) + } + + // MARK: - Connection wire behavior + + private struct Wire { + let connection: RemoteTmuxControlConnection + let writer: RemoteTmuxControlPipeWriter + let pipe: Pipe + } + + private func makeWire(label: String) -> Wire { + let connection = RemoteTmuxControlConnection( + host: RemoteTmuxHost(destination: "user@env-push.test"), sessionName: "work" + ) + let pipe = Pipe() + let writer = RemoteTmuxControlPipeWriter( + handle: pipe.fileHandleForWriting, + label: label, + maxPendingBytes: 1 << 16, + onFailure: {} + ) + connection.installStdinWriterForTesting(writer) + return Wire(connection: connection, writer: writer, pipe: pipe) + } + + private func attachWriter(_ wire: inout Wire, label: String) { + let pipe = Pipe() + let writer = RemoteTmuxControlPipeWriter( + handle: pipe.fileHandleForWriting, + label: label, + maxPendingBytes: 1 << 16, + onFailure: {} + ) + wire.connection.installStdinWriterForTesting(writer) + wire = Wire(connection: wire.connection, writer: writer, pipe: pipe) + } + + private func drainPendingCommands(_ connection: RemoteTmuxControlConnection) { + while let kind = connection.pendingCommandKindsForTesting.first { + let lines: [String] + if case .paneRects = kind { + lines = ["%0 0 0 80 24 1 off :0 \"host\""] + } else { + lines = [] + } + connection.handleMessageForTesting( + .commandResult(commandNumber: 2, lines: lines, isError: false) + ) + } + } + + private func sentCommands(_ wire: Wire) throws -> [String] { + wire.writer.close() + let data = try wire.pipe.fileHandleForReading.readToEnd() ?? Data() + try? wire.pipe.fileHandleForReading.close() + return String(decoding: data, as: UTF8.self) + .split(separator: "\n") + .map(String.init) + } + + /// First connect: the push rides the same post-attach alignment point as + /// `applyClientSize` — after the attach block is drained and the first + /// `list-windows` result lands. + @Test func firstConnectPushesMarkerAndIdentitySessionScoped() throws { + var wire = makeWire(label: "remote-tmux-env-push-first-connect") + let connection = wire.connection + connection.setMirrorEnvironment(["CMUX_WORKSPACE_ID": "11111111-2222-3333-4444-555555555555"]) + + connection.handleMessageForTesting(.enter) + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: [], isError: false) + ) + connection.pendingAttachRedrawKick = false + connection.handleMessageForTesting(.commandResult( + commandNumber: 1, + lines: ["@1 f92f,80x24,0,0,0 f92f,80x24,0,0,0 [] main"], + isError: false + )) + drainPendingCommands(connection) + + let commands = try sentCommands(wire) + let pushes = commands.filter { $0.hasPrefix("set-environment") } + // No %session-changed arrived, so the target is the quoted session name. + #expect(pushes.contains("set-environment -t 'work' CMUX_REMOTE_TMUX_MIRROR '1'")) + #expect(pushes.contains( + "set-environment -t 'work' CMUX_WORKSPACE_ID '11111111-2222-3333-4444-555555555555'" + )) + // Session scope is the point of the fix: `-g` values are invisible to + // the session-scoped `show-environment` the shell integration runs. + #expect(pushes.allSatisfy { !$0.contains(" -g ") }) + // No relay exists on the ssh-tmux transport, so a local socket path + // must never be published to the remote. + #expect(commands.allSatisfy { !$0.contains("CMUX_SOCKET_PATH") }) + wire.connection.stop() + } + + /// Reconnect: the `.reseed` post-attach branch pushes again, so values + /// stale from before the drop (or an app relaunch) are refreshed. + @Test func reconnectPushesAgain() throws { + var wire = makeWire(label: "remote-tmux-env-push-reconnect-a") + let connection = wire.connection + connection.setMirrorEnvironment(["CMUX_WORKSPACE_ID": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"]) + connection.handleMessageForTesting(.enter) + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: [], isError: false) + ) + connection.handleMessageForTesting(.commandResult( + commandNumber: 1, + lines: ["@1 f92f,80x24,0,0,0 f92f,80x24,0,0,0 [] main"], + isError: false + )) + drainPendingCommands(connection) + _ = try sentCommands(wire) + + connection.beginReconnecting() + attachWriter(&wire, label: "remote-tmux-env-push-reconnect-b") + connection.handleMessageForTesting(.enter) + // The FIFO was drained above; requesting windows and answering models + // the post-reconnect list-windows that consumes `.reseed`. + connection.requestWindows() + connection.handleMessageForTesting(.commandResult( + commandNumber: 3, + lines: ["@1 f92f,80x24,0,0,0 f92f,80x24,0,0,0 [] main"], + isError: false + )) + drainPendingCommands(connection) + + let commands = try sentCommands(wire) + let pushes = commands.filter { $0.hasPrefix("set-environment") } + #expect(pushes.contains("set-environment -t 'work' CMUX_REMOTE_TMUX_MIRROR '1'")) + #expect(pushes.contains( + "set-environment -t 'work' CMUX_WORKSPACE_ID 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'" + )) + #expect(pushes.allSatisfy { !$0.contains(" -g ") }) + connection.stop() + } + + /// The controller seeds the identity pairs when it creates the mirror, so + /// the connection knows the workspace before the first push fires. + @Test func mirrorSessionSeedsWorkspaceIdentity() throws { + let appDelegate = try #require(AppDelegate.shared) + let windowID = appDelegate.createMainWindow() + defer { + let identifier = "cmux.main.\(windowID.uuidString)" + NSApp.windows.first { $0.identifier?.rawValue == identifier }?.performClose(nil) + appDelegate.forgetRecoverableMainWindowRoute(windowId: windowID) + } + let manager = try #require(appDelegate.tabManagerFor(windowId: windowID)) + let controller = RemoteTmuxController() + let host = RemoteTmuxHost(destination: "user@env-identity.test") + let connection = RemoteTmuxControlConnection(host: host, sessionName: "work") + let pipe = Pipe() + let writer = RemoteTmuxControlPipeWriter( + handle: pipe.fileHandleForWriting, + label: "remote-tmux-env-push-identity", + maxPendingBytes: 1 << 16, + onFailure: {} + ) + connection.installStdinWriterForTesting(writer) + connection.handleMessageForTesting(.enter) + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: [], isError: false) + ) + controller.cacheConnection(connection) + #expect(try controller.mirrorSession(host: host, sessionName: "work", into: manager)) + defer { + controller.detach(host: host, sessionName: "work") + writer.close() + try? pipe.fileHandleForReading.close() + } + + let workspace = try #require(manager.tabs.first { $0.isRemoteTmuxMirror }) + #expect(connection.mirrorEnvironment["CMUX_WORKSPACE_ID"] == workspace.id.uuidString) + #expect(connection.mirrorEnvironment["CMUX_TAB_ID"] == workspace.id.uuidString) + #expect(connection.mirrorEnvironment["CMUX_SOCKET_PATH"] == nil) + } +} diff --git a/cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift b/cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift new file mode 100644 index 00000000000..07ff4247bf2 --- /dev/null +++ b/cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift @@ -0,0 +1,252 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Tests the OSC 777/9 desktop-notification interceptor used on mirrored +/// `%output` (issue #833). The filter must strip a complete notification +/// sequence (reporting `(title, body)`), survive chunk splits at any byte, +/// pass every other OSC through byte-identical, and never buffer an +/// unfinished candidate past its ceiling. +/// +/// Assertions compare raw `Data` (not UTF-8-decoded strings): the filter is a +/// byte-stream transform, and `String(decoding:as:)` silently replaces invalid +/// UTF-8 — which would mask a byte-corruption regression instead of failing. +@Suite struct RemoteTmuxNotificationOSCFilterTests { + private func run( + _ chunks: [String] + ) -> (output: Data, notifications: [(title: String, body: String)]) { + var filter = RemoteTmuxNotificationOSCFilter() + var out = Data() + var notifications: [(title: String, body: String)] = [] + for chunk in chunks { + out.append(filter.filter(Data(chunk.utf8)) { title, body in + notifications.append((title, body)) + }) + } + return (out, notifications) + } + + private func run( + _ s: String + ) -> (output: Data, notifications: [(title: String, body: String)]) { + run([s]) + } + + private func bytes(_ s: String) -> Data { Data(s.utf8) } + + private let ESC = "\u{1b}" + private let BEL = "\u{07}" + private var ST: String { "\(ESC)\\" } + + // MARK: - Complete sequences + + @Test func stripsBelTerminatedOsc777AndReportsTitleBody() { + let result = run("before\(ESC)]777;notify;Build;done\(BEL)after") + #expect(result.output == bytes("beforeafter")) + #expect(result.notifications.count == 1) + #expect(result.notifications.first?.title == "Build") + #expect(result.notifications.first?.body == "done") + } + + @Test func stripsStTerminatedOsc777AndReportsTitleBody() { + let result = run("a\(ESC)]777;notify;T;B\(ST)z") + #expect(result.output == bytes("az")) + #expect(result.notifications.count == 1) + #expect(result.notifications.first?.title == "T") + #expect(result.notifications.first?.body == "B") + } + + @Test func stripsOsc9WithEmptyTitle() { + let bel = run("x\(ESC)]9;hello\(BEL)y") + #expect(bel.output == bytes("xy")) + #expect(bel.notifications.count == 1) + #expect(bel.notifications.first?.title == "") + #expect(bel.notifications.first?.body == "hello") + + let st = run("x\(ESC)]9;hello\(ST)y") + #expect(st.output == bytes("xy")) + #expect(st.notifications.count == 1) + #expect(st.notifications.first?.body == "hello") + } + + @Test func titleOnlyOsc777ReportsEmptyBody() { + // `777;notify;<title>` with no body separator is still a notification. + let result = run("\(ESC)]777;notify;JustTitle\(BEL)") + #expect(result.output == bytes("")) + #expect(result.notifications.count == 1) + #expect(result.notifications.first?.title == "JustTitle") + #expect(result.notifications.first?.body == "") + } + + @Test func bodyMayContainSemicolons() { + // Only the FIRST separator after `notify;` splits title from body. + let result = run("\(ESC)]777;notify;t;a;b;c\(BEL)") + #expect(result.notifications.first?.title == "t") + #expect(result.notifications.first?.body == "a;b;c") + } + + @Test func utf8MultibyteBodySurvives() { + let result = run("\(ESC)]777;notify;构建;完成 ✅ émoji\(BEL)") + #expect(result.output == bytes("")) + #expect(result.notifications.first?.title == "构建") + #expect(result.notifications.first?.body == "完成 ✅ émoji") + } + + @Test func multipleNotificationsInOneChunkReportInOrder() { + let result = run("1\(ESC)]9;first\(BEL)2\(ESC)]777;notify;t;second\(ST)3") + #expect(result.output == bytes("123")) + #expect(result.notifications.map { $0.body } == ["first", "second"]) + } + + // MARK: - Chunk splits + + @Test func survivesChunkSplitsAtEveryBoundary() { + // Covers all the interesting cuts: right after ESC, inside `]777;notify;`, + // at each semicolon, mid-title, mid-body, and before/inside the ST + // terminator. + let full = "X\(ESC)]777;notify;ab;cd\(ST)Y" + let allBytes = Array(full.utf8) + for cut in 1..<allBytes.count { + var filter = RemoteTmuxNotificationOSCFilter() + var out = Data() + var notifications: [(String, String)] = [] + out.append(filter.filter(Data(allBytes[0..<cut])) { notifications.append(($0, $1)) }) + out.append(filter.filter(Data(allBytes[cut...])) { notifications.append(($0, $1)) }) + #expect(out == bytes("XY"), "split at \(cut)") + #expect(notifications.count == 1, "split at \(cut)") + #expect(notifications.first?.0 == "ab", "split at \(cut)") + #expect(notifications.first?.1 == "cd", "split at \(cut)") + } + } + + @Test func survivesChunkSplitsWithBelTerminator() { + let full = "X\(ESC)]9;body\(BEL)Y" + let allBytes = Array(full.utf8) + for cut in 1..<allBytes.count { + var filter = RemoteTmuxNotificationOSCFilter() + var out = Data() + var bodies: [String] = [] + out.append(filter.filter(Data(allBytes[0..<cut])) { bodies.append($1) }) + out.append(filter.filter(Data(allBytes[cut...])) { bodies.append($1) }) + #expect(out == bytes("XY"), "split at \(cut)") + #expect(bodies == ["body"], "split at \(cut)") + } + } + + @Test func survivesMultibyteSplitInsideUtf8Body() { + // Cut inside the 3-byte UTF-8 encoding of 中. + let full = Array("\(ESC)]9;中文\(BEL)ok".utf8) + var filter = RemoteTmuxNotificationOSCFilter() + var out = Data() + var bodies: [String] = [] + out.append(filter.filter(Data(full[0..<5])) { bodies.append($1) }) // mid-中 + out.append(filter.filter(Data(full[5...])) { bodies.append($1) }) + #expect(out == bytes("ok")) + #expect(bodies == ["中文"]) + } + + // MARK: - Pass-through + + @Test func nonNotifyOsc777SubcommandPassesVerbatim() { + let sequence = "\(ESC)]777;other;payload\(BEL)" + let result = run("a\(sequence)b") + #expect(result.output == bytes("a\(sequence)b")) + #expect(result.notifications.isEmpty) + } + + @Test func otherOscSequencesPassVerbatim() { + // Window title (OSC 0), hyperlink (OSC 8), clipboard (OSC 52) — both + // terminators, byte-identical. + for sequence in [ + "\(ESC)]0;my title\(BEL)", + "\(ESC)]8;;https://example.com\(ST)link\(ESC)]8;;\(ST)", + "\(ESC)]52;c;aGVsbG8=\(BEL)", + ] { + let result = run("L\(sequence)R") + #expect(result.output == bytes("L\(sequence)R")) + #expect(result.notifications.isEmpty) + } + } + + @Test func passThroughOscSurvivesChunkSplits() { + let full = "L\(ESC)]0;title text\(BEL)R" + let allBytes = Array(full.utf8) + for cut in 1..<allBytes.count { + var filter = RemoteTmuxNotificationOSCFilter() + var out = Data() + out.append(filter.filter(Data(allBytes[0..<cut])) { _, _ in }) + out.append(filter.filter(Data(allBytes[cut...])) { _, _ in }) + #expect(out == bytes(full), "split at \(cut)") + } + } + + @Test func csiAndOtherEscapesPassUntouched() { + let input = "\(ESC)[31mred\(ESC)[0m \(ESC)[2J\(ESC)[H plain" + let result = run(input) + #expect(result.output == bytes(input)) + #expect(result.notifications.isEmpty) + } + + @Test func interleavedOutputAroundNotificationIsByteIdentical() { + let result = run([ + "\(ESC)[1mbold\(ESC)[0m", + "\(ESC)]777;notify;t;b\(BEL)", + "tail\r\n", + ]) + #expect(result.output == bytes("\(ESC)[1mbold\(ESC)[0mtail\r\n")) + #expect(result.notifications.count == 1) + } + + @Test func bareEscInsideCandidateFlushesVerbatim() { + // An OSC payload cannot legally contain a bare ESC (other than ST); the + // malformed sequence must not be swallowed. + let input = "\(ESC)]777;no\(ESC)[31mtify" + let result = run(input) + #expect(result.output == bytes(input)) + #expect(result.notifications.isEmpty) + } + + @Test func tooShortOsc9CandidatePassesVerbatim() { + // `ESC ] 9 BEL` (no `;`) is not a notification. + let input = "\(ESC)]9\(BEL)" + let result = run(input) + #expect(result.output == bytes(input)) + #expect(result.notifications.isEmpty) + } + + // MARK: - Buffer ceiling + + @Test func oversizedUnfinishedCandidatePassesVerbatim() { + // A prefix-compatible sequence that exceeds the ceiling before its + // terminator is flushed verbatim — never stripped, never retained. + let hugeBody = String(repeating: "A", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes + 16) + let input = "\(ESC)]9;\(hugeBody)\(BEL)after" + let result = run(input) + #expect(result.output == bytes(input)) + #expect(result.notifications.isEmpty) + } + + @Test func oversizedCandidateSplitAcrossChunksPassesVerbatim() { + let hugeBody = String(repeating: "B", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes) + let result = run([ + "\(ESC)]777;notify;t;", + hugeBody, + "tail\(BEL)done", + ]) + #expect(result.output == bytes("\(ESC)]777;notify;t;\(hugeBody)tail\(BEL)done")) + #expect(result.notifications.isEmpty) + } + + @Test func maxSizedCompleteNotificationStillStrips() { + // Just under the ceiling must still work. + let body = String(repeating: "C", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 64) + let result = run("\(ESC)]9;\(body)\(BEL)") + #expect(result.output == bytes("")) + #expect(result.notifications.first?.body == body) + } +}