diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index de03e911c514..a2f7c8ff522f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -14776,7 +14776,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return streamToken } - private func unregisterTerminalOutput(surfaceID: String, streamToken: UUID) { + private func unregisterTerminalOutput( + surfaceID: String, + streamToken: UUID, + releaseViewport: Bool + ) { guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else { return } terminalLatencyObserver.surfaceClosed(surfaceID: surfaceID) terminalLaneOutputReadySurfaceIDs.remove(surfaceID) @@ -14833,8 +14837,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { .terminalUnmounted, correlationID: surfaceID ) - // Tell the Mac this device is no longer viewing the surface so it can unpin and clear its border. - clearTerminalViewport(surfaceID: surfaceID) + if releaseViewport { + // Ownerless and release-gate streams keep the historical contract: + // ending the stream means the viewer lease ended too. Mounted UI + // streams pass false here because UIKit/output-consumer churn is a + // narrower lifetime than presentation ownership. + clearTerminalViewport(surfaceID: surfaceID) + } } /// The output byte stream for a terminal surface. @@ -14858,6 +14867,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public func terminalOutputStream( surfaceID: String, ownerID: UUID? + ) -> AsyncStream { + terminalOutputStream( + surfaceID: surfaceID, + ownerID: ownerID, + releaseViewportOnTermination: true + ) + } + + /// Opens an owner-aware output stream with explicit viewport-lease + /// termination semantics. + /// + /// Mounted UIKit consumers set `releaseViewportOnTermination` to false: + /// their stream can end during a temporary window detach or bounded + /// consumer restart while the presentation still owns the sticky viewport + /// report. Presentation teardown releases that report explicitly through + /// `clearTerminalViewport(surfaceID:)`. + public func terminalOutputStream( + surfaceID: String, + ownerID: UUID?, + releaseViewportOnTermination: Bool ) -> AsyncStream { AsyncStream { continuation in let streamToken = registerTerminalOutput( @@ -14869,7 +14898,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { Task { @MainActor in self?.unregisterTerminalOutput( surfaceID: surfaceID, - streamToken: streamToken + streamToken: streamToken, + releaseViewport: releaseViewportOnTermination ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index a8f52a5482d6..a240fbd27990 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -513,9 +513,9 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { } } ) - // Drive every output chunk into the libghostty surface. Ending this - // task terminates the stream, which unregisters the surface and - // clears its viewport pin on the Mac (see `terminalOutputStream`). + // Drive every output chunk into the libghostty surface. The output + // stream owns delivery only; this coordinator's presentation owns + // the sticky viewport lease and releases it explicitly on teardown. outputTaskGeneration &+= 1 let taskGeneration = outputTaskGeneration let ownerID = UUID() @@ -539,7 +539,8 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { guard let store else { return } for await chunk in store.terminalOutputStream( surfaceID: surfaceID, - ownerID: ownerID + ownerID: ownerID, + releaseViewportOnTermination: false ) { guard !Task.isCancelled else { return } guard let self else { return }