From 288fde6546f37d91d0d06a26fe4e3069e3569471 Mon Sep 17 00:00:00 2001 From: icefery Date: Wed, 28 Oct 2026 12:35:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(macos):=20=E7=BB=88=E7=AB=AF=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=8D=87=E7=BA=A7=E5=88=B0=20SwiftTerm=202.0=20?= =?UTF-8?q?=E5=B9=B6=E5=90=AF=E7=94=A8=20Metal=20=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-21-worktree-resource-reuse.md | 14 +- Package.resolved | 23 +- Package.swift | 4 +- .../MacOS/Terminal/MacTerminalTransport.swift | 740 +++++++++++------- .../MacTerminalBackgroundReportingTests.swift | 147 ---- .../LitheTests/MacTerminalPTYTests.swift | 74 ++ .../LitheTests/TerminalInputFocusTests.swift | 244 +++--- .../TerminalScrollbackSettingsTests.swift | 19 +- scripts/measure-macos-performance-baseline.sh | 2 - scripts/package-app.sh | 6 +- scripts/preview.sh | 2 +- scripts/verify-macos-app-build-safety.sh | 4 - scripts/verify-macos-package.sh | 1 - .../features/terminal-canvas-compositing.json | 7 +- 14 files changed, 717 insertions(+), 570 deletions(-) delete mode 100644 macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift create mode 100644 macos/Tests/LitheTests/MacTerminalPTYTests.swift diff --git a/.agents/notes/implemented/process/2026-09-21-worktree-resource-reuse.md b/.agents/notes/implemented/process/2026-09-21-worktree-resource-reuse.md index 3eaecea8bb..22f1ff2bfb 100644 --- a/.agents/notes/implemented/process/2026-09-21-worktree-resource-reuse.md +++ b/.agents/notes/implemented/process/2026-09-21-worktree-resource-reuse.md @@ -5,7 +5,7 @@ ## 先说结论 Git linked worktree 之间只复用校验过的下载缓存,不共享 `.build` 或旧预览应用。 -目标工作树仍须自己编译,并在打包预览应用时复制两种 SwiftPM 资源包。 +目标工作树仍须自己编译,并在打包预览应用时复制 `Lithe_Lithe.bundle`。 漏掉应用自己的资源包会让使用内置工作台背景的应用在启动时崩溃。 ## 问题 @@ -16,7 +16,9 @@ Git linked worktree 之间只复用校验过的下载缓存,不共享 `.build` 下载缓存可用也不等于预览应用可用。`scripts/preview.sh` 曾只复制 `SwiftTerm_SwiftTerm.bundle`,漏掉 `Lithe_Lithe.bundle`;当已选择内置工作台背景时, 应用初始化读取 `Bundle.module` 会因资源包不存在而崩溃。漏拷资源在工作树复用机制 -出现前就已存在,切换工作树或再次运行旧脚本只是让这一缺口重新暴露。 +出现前就已存在,切换工作树或再次运行旧脚本只是让这一缺口重新暴露。当时脚本还要求 +`SwiftTerm_SwiftTerm.bundle`,但 SwiftTerm 2.0 已把 Metal shader 内嵌在源码中, +不再提供该资源包。 ## 决策 @@ -27,9 +29,9 @@ Git linked worktree 之间只复用校验过的下载缓存,不共享 `.build` 加入可复用清单;这些内容必须在目标工作树重新生成。 SwiftPM 下载缓存保存依赖,不包含应用编译后需要的资源包。`scripts/preview.sh` 和 -`scripts/package-app.sh` 应从当前工作树的构建目录复制 `Lithe_Lithe.bundle` 与 -`SwiftTerm_SwiftTerm.bundle`,缺少任一资源包就应在启动前报错。 -`scripts/verify-macos-app-build-safety.sh` 检查两个打包脚本均声明这两种资源。 +`scripts/package-app.sh` 应从当前工作树的构建目录复制 `Lithe_Lithe.bundle`,缺少 +该资源包就应在启动前报错。`scripts/verify-macos-app-build-safety.sh` 检查两个打包 +脚本均声明 `Lithe_Lithe.bundle`。 正确做法是切换工作树后重新运行预览脚本并检查新包;不要继续运行此前生成的 `.app`。 ## 考虑过的备选方案 @@ -54,7 +56,7 @@ SwiftPM 下载缓存保存依赖,不包含应用编译后需要的资源包。 - `node scripts/test-verify-download-cache.mjs` - `node scripts/test-reuse-worktree-resources.mjs` -- `./scripts/verify-macos-app-build-safety.sh`:确认两个打包脚本均声明所需资源包。 +- `./scripts/verify-macos-app-build-safety.sh`:确认两个打包脚本均声明 `Lithe_Lithe.bundle`。 - `./scripts/preview.sh`:检查新包包含 `Lithe_Lithe.bundle`,并在内置背景设置下启动。 - `./scripts/verify-agent-notes.sh` diff --git a/Package.resolved b/Package.resolved index acdff08507..63a042ce62 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "d95991df214742bb086884f143e869ad8b9a0d73b010c0c12c648801551f77be", + "originHash" : "46d20e6129466f17177ff89a810fd73f40930fcaebf5bbf558c808cc528686e8", "pins" : [ + { + "identity" : "h", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rarestype/h", + "state" : { + "revision" : "aa3626829160917d4378330617971977cbd78f5b", + "version" : "1.0.1" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -19,6 +28,15 @@ "version" : "1.8.2" } }, + { + "identity" : "swift-png", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tayloraswift/swift-png", + "state" : { + "revision" : "2d17c6c51c098628574c0383f1119892655a73a2", + "version" : "4.5.2" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -42,8 +60,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/migueldeicaza/SwiftTerm.git", "state" : { - "revision" : "dd2fb8ac5b861e7bf617c872895e338f38165648", - "version" : "1.15.0" + "revision" : "367958aec31c78a7c64630e3265a0a900e35f922" } } ], diff --git a/Package.swift b/Package.swift index f060479dae..160ff7137c 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.10.0"), - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", exact: "1.15.0"), + // Pinned to the upstream commit that merged the first-frame fix (#778). + // Switch to `from:` once an upstream tag includes it. + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", revision: "367958aec31c78a7c64630e3265a0a900e35f922"), .package(url: "https://github.com/swiftlang/swift-testing.git", exact: "6.2.4") ], targets: [ diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index a518205334..599a74dfa8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -3,30 +3,56 @@ import Foundation import MetalKit import SwiftTerm import LitheTerminalModule +import os /// SwiftTerm's default link handler opens URLs in the system. Lithe needs the /// link event so workspace-relative paths can open in its own editor instead. -final class LitheTerminalView: LocalProcessTerminalView { +final class LitheTerminalView: SwiftTerm.TerminalView, SwiftTerm.TerminalViewDelegate { static let focusDidChange = Notification.Name("LitheTerminalView.focusDidChange") var onOpenLink: ((String, [String: String]) -> Void)? - var onProcessOutput: ((Data) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onProcessTermination: ((Int32?) -> Void)? + private nonisolated let processHostLock = NSLock() + private nonisolated(unsafe) var storedProcessHost: MacTerminalProcessHost? + /// Lock-protected because the parse-thread response path reads it. + nonisolated var processHost: MacTerminalProcessHost? { + get { + processHostLock.lock() + defer { processHostLock.unlock() } + return storedProcessHost + } + set { + processHostLock.lock() + storedProcessHost = newValue + processHostLock.unlock() + } + } private var showsWorkbenchBackground = false - private weak var metalActivationFailedWindow: NSWindow? - private var metalFocusDelegates: [TerminalMetalFocusDelegate] = [] - private var shellShowsCursor = true + private var requestedCaretColor = NSColor.selectedControlColor + private var visibleCaretColor: NSColor? + private nonisolated let caretRefreshLock = NSLock() + private nonisolated(unsafe) var caretRefreshScheduled = false + private nonisolated(unsafe) var caretRefreshPending = false /// App theme light/dark preference while the view is not yet in a window; nil follows the system appearance. var preferredAppearanceIsDark: (() -> Bool?)? - private var lastReportedBackgroundPayload: String? - private var backgroundChangeNotifications = MacTerminalBackgroundSubscription() + private static let logger = Logger(subsystem: "com.openres.Lithe", category: "Terminal") + /// Windows whose Metal enablement already failed, so the fallback is logged once per window. + private let metalFallbackWindows = NSHashTable.weakObjects() override init(frame frameRect: NSRect) { super.init(frame: frameRect) - configureRendererPolicy() + terminalDelegate = self + } + + init(frame: NSRect, font: NSFont?, startupOptions: TerminalOptions) { + super.init(frame: frame, font: font, options: startupOptions) + terminalDelegate = self } required init?(coder: NSCoder) { super.init(coder: coder) - configureRendererPolicy() + terminalDelegate = self } override func viewDidMoveToWindow() { @@ -40,39 +66,38 @@ final class LitheTerminalView: LocalProcessTerminalView { } } windowFocusDidChange() - guard let currentWindow = window, - !isUsingMetalRenderer, - currentWindow !== metalActivationFailedWindow else { return } + enableMetalRendererIfAvailable() + } + /// Enables the Metal renderer once the view has a window to bind its `CAMetalLayer` to. + /// A host without a usable Metal device or shader pipeline keeps the Core Graphics renderer. + /// That fallback is logged once per window rather than thrown at the embedding view. + private func enableMetalRendererIfAvailable() { + guard let window, !isUsingMetalRenderer else { return } do { try setUseMetal(true) - metalActivationFailedWindow = nil - invalidateCursorSurface() + metalFallbackWindows.remove(window) } catch { - // A different window gets one fresh attempt because SwiftTerm's - // CAMetalLayer must be rebound when the persistent terminal moves. - metalActivationFailedWindow = currentWindow - NSLog( - "Lithe terminal Metal renderer could not be enabled; using Core Graphics: %@", - String(describing: error) + guard !metalFallbackWindows.contains(window) else { return } + metalFallbackWindows.add(window) + Self.logger.error( + "Metal renderer unavailable; keeping Core Graphics: \(String(describing: error), privacy: .public)", ) } } - private func configureRendererPolicy() { - // Ghostty's renderer keeps unchanged terminal content on the GPU and - // redraws only invalidated cells. SwiftTerm's persistent row mode gives - // Lithe the same workload shape while its paused MTKView remains - // event-driven instead of running a continuous display loop. - metalBufferingMode = .perRowPersistent - } - override func hitTest(_ point: NSPoint) -> NSView? { let hitView = super.hitTest(point) // SwiftTerm's Metal surface only renders pixels. Returning it as the // mouse target leaves keyboard focus in the previously active editor. // Preserve interactive children such as the scrollbar and find field. - return hitView is MTKView ? self : hitView + if hitView is MTKView { + return self + } + if hitView?.layer is CAMetalLayer { + return self + } + return hitView } override func mouseDown(with event: NSEvent) { @@ -83,24 +108,95 @@ final class LitheTerminalView: LocalProcessTerminalView { } override var hasFocus: Bool { - // AppKit's actual responder is authoritative; SwiftTerm's cached flag - // can outlive a responder transition while a persistent surface moves. - get { window?.isKeyWindow == true && window?.firstResponder === self } + // SwiftTerm's cached flag is part of the answer: become/resign and the other first-responder transitions keep it synchronized. + // AppKit's key-window and responder state guard against a stale cache while a persistent surface moves between windows. + get { super.hasFocus && window?.isKeyWindow == true && window?.firstResponder === self } set { super.hasFocus = newValue - setNativeCursorVisible(newValue && window?.isKeyWindow == true && shellShowsCursor) + applyCaretFocus(newValue && window?.isKeyWindow == true) invalidateCursorSurface() notifyFocusChange(newValue && window?.isKeyWindow == true) } } - private func setNativeCursorVisible(_ visible: Bool) { - if visible { super.showCursor(source: terminal) } - else { super.hideCursor(source: terminal) } + /// Hides the caret on an unfocused surface and restores the color that was visible there. + /// An application-set OSC 12 color is therefore not replaced by Lithe's theme color on the next focus transition. + private func applyCaretFocus(_ focused: Bool) { + if focused { + let color = super.caretColor.alphaComponent > 0 + ? super.caretColor + : (visibleCaretColor ?? requestedCaretColor) + super.caretColor = color + visibleCaretColor = nil + } else if super.caretColor.alphaComponent > 0 { + visibleCaretColor = super.caretColor + super.caretColor = .clear + } + } + + /// Records the theme caret while keeping the inactive surface transparent. + /// + /// SwiftTerm's `caretColor` is public but not open, so Lithe cannot override it. + /// Every theme write goes through this helper instead. + private func setCaretColor(_ color: NSColor) { + let previous = requestedCaretColor + requestedCaretColor = color + if hasFocus { + super.caretColor = color + visibleCaretColor = nil + } else { + if super.caretColor.alphaComponent > 0 { + visibleCaretColor = super.caretColor + } + if visibleCaretColor == nil || visibleCaretColor == previous { + visibleCaretColor = color + } + super.caretColor = .clear + } + } + + /// Re-applies focus state after OSC 12 wrote the caret directly on main. + func refreshCaretFocus() { + guard !hasFocus else { return } + applyCaretFocus(false) + invalidateCursorSurface() + } + + /// Coalesces caret refresh requests from the IO parse thread. + /// At most one main-queue refresh is queued, but a request that arrives while the refresh runs schedules one more. + /// An OSC 12 hop dispatched after the current refresh still gets the unfocused caret cleared. + nonisolated func requestCaretRefresh() { + caretRefreshLock.lock() + caretRefreshPending = true + if caretRefreshScheduled { + caretRefreshLock.unlock() + return + } + caretRefreshScheduled = true + caretRefreshLock.unlock() + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.runCaretRefresh() + } + } + } + + private func runCaretRefresh() { + caretRefreshLock.lock() + caretRefreshPending = false + caretRefreshScheduled = false + caretRefreshLock.unlock() + refreshCaretFocus() + caretRefreshLock.lock() + let again = caretRefreshPending + caretRefreshLock.unlock() + if again { + requestCaretRefresh() + } } @objc private func windowFocusDidChange() { - setNativeCursorVisible(hasFocus && shellShowsCursor) + applyCaretFocus(hasFocus) invalidateCursorSurface() notifyFocusChange(hasFocus) } @@ -113,33 +209,8 @@ final class LitheTerminalView: LocalProcessTerminalView { ) } - override func showCursor(source: Terminal) { - shellShowsCursor = true - if hasFocus { super.showCursor(source: source) } - else { super.hideCursor(source: source) } - } - - override func hideCursor(source: Terminal) { - shellShowsCursor = false - super.hideCursor(source: source) - } - @objc private func invalidateCursorSurface() { - // SwiftTerm invalidates its Core Graphics caret on focus changes, but - // its paused Metal surface also needs a frame when no output arrives. needsDisplay = true - for case let renderer as MTKView in subviews { - renderer.layer?.isOpaque = !showsWorkbenchBackground - if !(renderer.delegate is TerminalMetalFocusDelegate), let delegate = renderer.delegate { - let focusDelegate = TerminalMetalFocusDelegate( - view: renderer, terminalView: self, renderer: delegate - ) - metalFocusDelegates.append(focusDelegate) - renderer.delegate = focusDelegate - } - renderer.setNeedsDisplay(renderer.bounds) - } - metalFocusDelegates.removeAll { $0.view?.superview !== self } } override func menu(for event: NSEvent) -> NSMenu? { @@ -183,11 +254,7 @@ final class LitheTerminalView: LocalProcessTerminalView { // Metal clear colors are premultiplied; RGB with zero alpha over-exposes glyph edges on light backgrounds. let showsWallpaper = showsWorkbenchBackground nativeBackgroundColor = showsWallpaper ? .black.withAlphaComponent(0) : editorColor - layer?.backgroundColor = (showsWallpaper ? NSColor.clear : editorColor).cgColor layer?.isOpaque = !showsWallpaper - for case let renderer as MTKView in subviews { - renderer.layer?.isOpaque = !showsWallpaper - } // OSC 11 answers with this same color. // With a wallpaper the canvas is transparent, so applications are told black. // Reporting an opaque color there would make the compositor blend it twice. @@ -195,9 +262,9 @@ final class LitheTerminalView: LocalProcessTerminalView { // own opaque background. The terminal is composited over Lithe's // editor or wallpaper, so keep the default text tied to the app theme. nativeForegroundColor = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) - caretColor = isDark + setCaretColor(isDark ? NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1) - : NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1) + : NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1)) selectedTextBackgroundColor = isDark ? NSColor(srgbRed: 0.16, green: 0.31, blue: 0.48, alpha: 1) : NSColor(srgbRed: 0.69, green: 0.82, blue: 0.98, alpha: 1) @@ -210,21 +277,8 @@ final class LitheTerminalView: LocalProcessTerminalView { blue: UInt16(fallback & 0xff) * 257 ) }) + updateColorScheme(showsWallpaper || isDark ? .dark : .light, notify: true) needsDisplay = true - reportBackgroundChangeIfNeeded() - } - - /// Reports background changes only to programs subscribed to kitty DEC mode 2031. - private func reportBackgroundChangeIfNeeded() { - let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor) - guard payload != lastReportedBackgroundPayload else { return } - lastReportedBackgroundPayload = payload - guard process.running, - let notification = MacTerminalBackgroundReporting.changeNotification( - for: nativeBackgroundColor, - isSubscribed: backgroundChangeNotifications.isSubscribed - ) else { return } - terminal.sendResponse(text: notification) } func updateWorkbenchSurface(isVisible: Bool) { @@ -237,132 +291,298 @@ final class LitheTerminalView: LocalProcessTerminalView { applyThemeColors(theme: theme, isDark: isDark) } + /// Current PTY window size derived from the rendered terminal grid. + func currentWindowSize() -> winsize { + let dimensions = terminalDimensions + return windowSize(cols: dimensions.cols, rows: dimensions.rows) + } + + private func windowSize(cols: Int, rows: Int) -> winsize { + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1 + return winsize( + ws_row: UInt16(clamping: rows), + ws_col: UInt16(clamping: cols), + ws_xpixel: UInt16(clamping: Int((bounds.width * scale).rounded())), + ws_ypixel: UInt16(clamping: Int((bounds.height * scale).rounded())), + ) + } + + override func layout() { + super.layout() + processHost?.updateWindowSize(currentWindowSize()) + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + processHost?.updateWindowSize(currentWindowSize()) + } + + func processTerminated(source _: LocalProcess, exitCode: Int32?) { + onProcessTermination?(exitCode) + } + + func processFailedToStart(source _: LocalProcess, error: LocalProcessError) { + feed(text: "\r\nProcess launch failed: \(error)\r\n") + } + + // MARK: TerminalViewDelegate + + /// Mirrors 1.15's synchronous path: DA/DSR/OSC replies go straight to the process that produced them, on its parse thread. + /// SwiftTerm 1.99 routes them through `onMain`, where a restart could deliver an old reply to the new process. + override nonisolated func send(source _: SwiftTerm.Terminal, data: ArraySlice) { + processHost?.process.send(data: data) + } + + func send(source _: SwiftTerm.TerminalView, data: ArraySlice) { + processHost?.process.send(data: data) + } - override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { + func sizeChanged(source _: SwiftTerm.TerminalView, newCols: Int, newRows: Int) { + processHost?.updateWindowSize(windowSize(cols: newCols, rows: newRows)) + } + + func setTerminalTitle(source _: SwiftTerm.TerminalView, title: String) { + onTitle?(title) + } + + func hostCurrentDirectoryUpdate(source _: SwiftTerm.TerminalView, directory: String?) { + onDirectoryUpdate?(directory) + } + + func scrolled(source _: SwiftTerm.TerminalView, position _: Double) {} + + func rangeChanged(source _: SwiftTerm.TerminalView, startY _: Int, endY _: Int) {} + + func requestOpenLink(source _: SwiftTerm.TerminalView, link: String, params: [String: String]) { onOpenLink?(link, params) } - override func dataReceived(slice: ArraySlice) { - backgroundChangeNotifications.feed(slice) - onProcessOutput?(Data(slice)) - super.dataReceived(slice: slice) + /// OSC 52 parity: these preserve LocalProcessTerminalView's clipboard behavior from 1.15. + /// They are not newly enabled by the 1.99 migration. + func clipboardCopy(source _: SwiftTerm.TerminalView, content: Data) { + guard let string = String(bytes: content, encoding: .utf8) else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([string as NSString]) } - func resetBackgroundChangeNotifications() { - backgroundChangeNotifications.reset() + func clipboardRead(source _: SwiftTerm.TerminalView) -> Data? { + // Denied: 1.99 answers OSC 52 reads asynchronously on main, so an old process's reply can cross a PTY restart into the new child. + // 1.15's synchronous path did not expose that window. + // Writes remain for parity; reads return once a generation-checked or setting-based implementation exists. + nil } deinit { + NotificationCenter.default.removeObserver(self) } } - -/// Reports changed backgrounds to subscribers; kitty DSR 997 uses 1 for dark and 2 for light. -enum MacTerminalBackgroundReporting { - static func osc11Payload(for color: NSColor) -> String { - let srgb = color.usingColorSpace(.sRGB) ?? color - var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1 - srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - func channel(_ value: CGFloat) -> String { - String(format: "%04x", UInt16(max(0, min(1, value)) * 65535)) +/// Owns the PTY for one terminal surface so Lithe still sees every raw byte. +/// +/// One host serves one launch. +/// A terminating `LocalProcess` cannot be reused, so a restart detaches the old host and installs a fresh one. +final class MacTerminalProcessHost: NSObject, LocalProcessDelegate { + /// Caps queued raw output when the main queue is busy. + /// The IO parse thread waits instead of growing memory, which also backpressures the PTY. + private static let maximumPendingOutputBytes = 4 * 1024 * 1024 + + private let condition = NSCondition() + private var attached = true + private weak var view: LitheTerminalView? + private var outputSink: (@Sendable (Data) -> Void)? + private var pendingOutput = Data() + private var outputScheduled = false + /// Data callbacks that have entered but not yet handed their slice over. + private var activeDataReceives = 0 + /// Set by flush/detach so output that loses the race for the condition cannot be attributed to the next session. + private var terminated = false + + private(set) var process: LocalProcess! + private let windowSizeLock = NSLock() + private var windowSize = winsize() + + var onOutput: (@Sendable (Data) -> Void)? { + get { + condition.lock() + defer { condition.unlock() } + return outputSink + } + set { + condition.lock() + outputSink = newValue + if newValue == nil { + pendingOutput.removeAll(keepingCapacity: false) + outputScheduled = false + } + condition.broadcast() + condition.unlock() } - return "rgb:\(channel(red))/\(channel(green))/\(channel(blue))" } - static func reportsDarkBackground(for color: NSColor) -> Bool { - let srgb = color.usingColorSpace(.sRGB) ?? color - var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1 - srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - func linearize(_ component: CGFloat) -> Double { - let value = min(1, max(0, Double(component))) - return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + init(view: LitheTerminalView) { + self.view = view + super.init() + process = LocalProcess(delegate: self, dispatchQueue: .main, directDelivery: true) + } + + /// Permanently stops this host from feeding the view or the transport. + /// Callers replace it with a new host for the next launch. + func detach() { + condition.lock() + attached = false + terminated = true + view = nil + outputSink = nil + pendingOutput.removeAll(keepingCapacity: false) + outputScheduled = false + condition.broadcast() + condition.unlock() + } + + func dataReceived(slice: ArraySlice) { + // Runs on the IO parse thread; `feed` accepts bytes from any thread. + // Holding the condition across the enqueue makes detach() wait until this batch has been handed over. + // A detached host therefore never feeds. + condition.lock() + guard attached, !terminated, let view else { + condition.unlock() + return + } + activeDataReceives += 1 + view.feed(byteArray: slice) + view.requestCaretRefresh() + enqueueOutputLocked(Data(slice)) + activeDataReceives -= 1 + condition.broadcast() + condition.unlock() + } + + func getWindowSize() -> winsize { + windowSizeLock.lock() + defer { windowSizeLock.unlock() } + return windowSize + } + + func updateWindowSize(_ size: winsize) { + windowSizeLock.lock() + let changed = windowSize.ws_row != size.ws_row + || windowSize.ws_col != size.ws_col + || windowSize.ws_xpixel != size.ws_xpixel + || windowSize.ws_ypixel != size.ws_ypixel + windowSize = size + windowSizeLock.unlock() + guard changed, process.running else { return } + var size = size + _ = process.updateWindowSize(&size) + } + + func processTerminated(_ source: LocalProcess, exitCode: Int32?) { + MainActor.assumeIsolated { + condition.lock() + guard attached else { + condition.unlock() + return + } + let view = view + condition.unlock() + // The final read callback has already queued its bytes. + // Flush them synchronously so termination cannot overtake output on `.main`. + flushOutput() + view?.processTerminated(source: source, exitCode: exitCode) } - let luminance = 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue) - return luminance < 0.5 } - static func changeNotification(for color: NSColor, isSubscribed: Bool) -> String? { - guard isSubscribed else { return nil } - let backgroundState = reportsDarkBackground(for: color) ? 1 : 2 - return "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;\(backgroundState)n" + func processFailedToStart(_ source: LocalProcess, error: LocalProcessError) { + MainActor.assumeIsolated { + condition.lock() + guard attached else { + condition.unlock() + return + } + let view = view + condition.unlock() + view?.processFailedToStart(source: source, error: error) + } } -} -/// Tracks kitty DEC mode 2031 so change notifications are sent only to programs that requested them. -struct MacTerminalBackgroundSubscription { - private(set) var isSubscribed = false - private var pendingEscape: [UInt8] = [] - - private static let escape = UInt8(0x1b) - private static let mode2031: [UInt8] = [0x32, 0x30, 0x33, 0x31] - private static let altScreenModes: [[UInt8]] = [ - [0x34, 0x37], - [0x31, 0x30, 0x34, 0x37], - [0x31, 0x30, 0x34, 0x39], - ] - private static let maximumSequenceLength = 64 - - init() { - pendingEscape.reserveCapacity(Self.maximumSequenceLength) - } - - mutating func feed(_ bytes: S) where S.Element == UInt8 { - for byte in bytes { - if pendingEscape.isEmpty { - if byte == Self.escape { pendingEscape.append(byte) } - continue - } - if byte == Self.escape { - pendingEscape = [Self.escape] - continue - } - pendingEscape.append(byte) - if pendingEscape == [Self.escape, 0x63] { - isSubscribed = false - pendingEscape.removeAll(keepingCapacity: true) - continue - } - if pendingEscape.count == 2 { - if byte != 0x5b { pendingEscape.removeAll(keepingCapacity: true) } - continue - } - if pendingEscape.count == 3 { - if byte != 0x3f { pendingEscape.removeAll(keepingCapacity: true) } - continue - } - if (0x40...0x7e).contains(byte) { - if byte == 0x68 || byte == 0x6c, - contains(Self.mode2031, in: pendingEscape[3..<(pendingEscape.count - 1)]) { - isSubscribed = byte == 0x68 - } else if byte == 0x6c, - leavesAltScreen(pendingEscape[3..<(pendingEscape.count - 1)]) { - // A subscribed TUI runs inside the alternate screen; when it leaves, the shell must not inherit pushes. - isSubscribed = false - } - pendingEscape.removeAll(keepingCapacity: true) - } else if pendingEscape.count >= Self.maximumSequenceLength { - pendingEscape.removeAll(keepingCapacity: true) + /// Appends one slice. The caller owns the condition lock and must unlock. + private func enqueueOutputLocked(_ data: Data) { + while attached, outputSink != nil, + pendingOutput.count >= Self.maximumPendingOutputBytes + { + condition.wait() + } + guard attached, outputSink != nil else { return } + pendingOutput.append(data) + if !outputScheduled { + outputScheduled = true + DispatchQueue.main.async { [weak self] in + self?.drainOutput() } } } - mutating func reset() { - isSubscribed = false - pendingEscape.removeAll(keepingCapacity: true) + private func drainOutput() { + condition.lock() + let data = pendingOutput + pendingOutput.removeAll(keepingCapacity: false) + outputScheduled = false + let sink = attached ? outputSink : nil + condition.broadcast() + condition.unlock() + if !data.isEmpty { + sink?(data) + } } - private func contains(_ parameter: [UInt8], in parameters: ArraySlice) -> Bool { - var start = parameters.startIndex - for index in parameters.indices where parameters[index] == 0x3b { - if parameters[start.. 0 { + if !condition.wait(until: deadline) { + break + } + } + if !pendingOutput.isEmpty { + data.append(pendingOutput) + pendingOutput.removeAll(keepingCapacity: false) + } + // Output that loses the race for this lock is dropped instead of being attributed to the next session. + // The tombstone closes the pre-lock window without making main wait on a parse. + terminated = true + outputScheduled = false + let sink = attached ? outputSink : nil + condition.broadcast() + condition.unlock() + if !data.isEmpty { + sink?(data) } - return parameters[start..) -> Bool { - Self.altScreenModes.contains { contains($0, in: parameters) } +/// Reports whether the terminal background is dark, using WCAG relative luminance. +enum MacTerminalBackgroundReporting { + static func reportsDarkBackground(for color: NSColor) -> Bool { + let srgb = color.usingColorSpace(.sRGB) ?? color + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + func linearize(_ component: CGFloat) -> Double { + let value = min(1, max(0, Double(component))) + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + let luminance = 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue) + return luminance < 0.5 } } @@ -455,53 +675,17 @@ struct MacTerminalPalette { return color } } - -/// SwiftTerm 1.15's Metal blink timer ignores focus. Draw the inactive cursor -/// transparently with a steady style, preserving shell state while hiding both -/// the caret and its blink animation outside the actual keyboard owner. -final class TerminalMetalFocusDelegate: NSObject, MTKViewDelegate { - weak var view: MTKView? - private weak var terminalView: LitheTerminalView? - private let renderer: MTKViewDelegate - - init(view: MTKView, terminalView: LitheTerminalView, renderer: MTKViewDelegate) { - self.view = view - self.terminalView = terminalView - self.renderer = renderer - } - - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { - renderer.mtkView(view, drawableSizeWillChange: size) - } - - func draw(in view: MTKView) { - guard let terminalView, !terminalView.hasFocus else { - renderer.draw(in: view) - return - } - let requestedStyle = terminalView.terminal.options.cursorStyle - let requestedColor = terminalView.caretColor - terminalView.terminal.options.cursorStyle = .steadyBlock - terminalView.caretColor = .clear - defer { - terminalView.terminal.options.cursorStyle = requestedStyle - terminalView.caretColor = requestedColor - } - // MTKView's paused, event-driven draw is synchronous on the main thread; - // restore the protocol state before any subsequent shell input is parsed. - renderer.draw(in: view) - } -} - /// Owns one persistent SwiftTerm surface and the local PTY process connected to it. /// The surface intentionally lives with the session instead of the SwiftUI view so /// switching terminal tabs or hiding the tool window does not reset a TUI screen. @MainActor -final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency LocalProcessTerminalViewDelegate { +final class MacTerminalTransport: NSObject, TerminalTransport { static func availableShells(fileManager: FileManager = .default) -> [String] { MacTerminalShellDiscovery.availableShells(fileManager: fileManager) } + let view: LitheTerminalView + private var processHost: MacTerminalProcessHost var onTermination: ((Int32?) -> Void)? var onOutput: ((Data) -> Void)? @@ -519,11 +703,11 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L private var pendingSessionTermination: Task? var isRunning: Bool { - view.process.running + processHost.process.running } var processID: Int32? { - let processID = view.process.shellPid + let processID = processHost.process.shellPid return processID > 0 ? processID : nil } @@ -541,32 +725,60 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L // Keep the exported COLORFGBG in sync with the OSC 11 answer: wallpaper transparency reports black, so it must export dark. var reportsLightBackground: Bool? { !MacTerminalBackgroundReporting.reportsDarkBackground(for: view.nativeBackgroundColor) } - private let scrollbackLines: Int - init(scrollbackLines: Int = 2_000, preferredAppearanceIsDark: (() -> Bool?)? = nil) { - self.scrollbackLines = scrollbackLines - view = LitheTerminalView(frame: .zero) + var options = TerminalOptions.default + options.termName = "xterm-256color" + options.scrollback = scrollbackLines + let terminalView = LitheTerminalView( + frame: .zero, + font: Self.preferredTerminalFont(), + startupOptions: options, + ) + view = terminalView + processHost = MacTerminalProcessHost(view: terminalView) super.init() - view.processDelegate = self - view.onOpenLink = { [weak self] link, params in + installProcessHost(processHost) + terminalView.preferredAppearanceIsDark = preferredAppearanceIsDark + terminalView.onOpenLink = { [weak self] link, params in self?.onLink?(link, params) } - view.onProcessOutput = { [weak self] data in - self?.onOutput?(data) + terminalView.onTitle = { [weak self] title in + self?.onTitle?(title) } - view.font = Self.preferredTerminalFont() - view.preferredAppearanceIsDark = preferredAppearanceIsDark - view.applyThemeColors(theme: LitheTheme.activeTheme) - view.allowMouseReporting = true - view.linkReporting = .implicit - view.linkHighlightMode = .hoverWithModifier - - var options = view.terminal.options - options.termName = "xterm-256color" - options.scrollback = scrollbackLines - view.terminal.options = options - view.terminal.setup(isReset: false) + terminalView.onDirectoryUpdate = { [weak self] directory in + self?.onDirectoryUpdate?(directory) + } + terminalView.onProcessTermination = { [weak self] exitCode in + self?.handleProcessTerminated(exitCode: exitCode) + } + terminalView.applyThemeColors(theme: LitheTheme.activeTheme) + terminalView.allowMouseReporting = true + terminalView.linkReporting = .implicit + terminalView.linkHighlightMode = .hoverWithModifier + } + + /// Points the view and the transport at one fresh PTY host. + private func installProcessHost(_ host: MacTerminalProcessHost) { + // The host drains output on the main queue, which shares FIFO ordering with LocalProcess's `.main` termination callback. + // The final output chunks therefore stay ahead of finishDecodedOutput(). + // Stay inline when already on main: an extra async hop would land after that termination callback. + host.onOutput = { [weak self] data in + if Thread.isMainThread { + MainActor.assumeIsolated { + self?.view.refreshCaretFocus() + self?.onOutput?(data) + } + return + } + DispatchQueue.main.async { + MainActor.assumeIsolated { + self?.view.refreshCaretFocus() + self?.onOutput?(data) + } + } + } + view.processHost = host } private static func preferredTerminalFont() -> NSFont { @@ -622,6 +834,10 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L environment: [String: String] ) throws -> Int32 { stop() + processHost.detach() + let host = MacTerminalProcessHost(view: view) + processHost = host + installProcessHost(host) suppressNextTermination = false launchedInteractiveShell = false let executablePath = try resolveExecutablePath( @@ -638,25 +854,24 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } selectedShellPath = executablePath - view.terminal.resetToInitialState() - view.resetBackgroundChangeNotifications() - var options = view.terminal.options - options.termName = environment["TERM"] ?? "xterm-256color" - view.terminal.options = options - view.terminal.setup(isReset: false) + view.resetToInitialState() let environmentArray = environment.keys.sorted().map { key in "\(key)=\(environment[key] ?? "")" } - view.startProcess( + host.updateWindowSize(view.currentWindowSize()) + let launchResult = host.process.startProcessChecked( executable: executablePath, args: launch.arguments, environment: environmentArray, currentDirectory: launch.workingDirectory ) + if case let .failure(error) = launchResult { + throw terminalError("Unable to start \(executablePath): \(error)") + } - guard view.process.running, let processID else { + guard host.process.running, let processID else { throw terminalError("Unable to start \(executablePath)") } session = MacProcessTable.info(processID: processID).map { MacTerminalSessionOwnership(leader: $0.identity) } @@ -664,13 +879,13 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } func send(_ input: Data) throws { - guard view.process.running else { return } - view.process.send(data: Array(input)[...]) + guard processHost.process.running else { return } + processHost.process.send(data: Array(input)[...]) } func interrupt() throws { - guard view.process.running else { return } - view.process.send(data: [UInt8(0x03)][...]) + guard processHost.process.running else { return } + processHost.process.send(data: [UInt8(0x03)][...]) } func focus() { @@ -679,7 +894,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } func clear() { - view.terminal.resetToInitialState() + view.resetToInitialState() } func stop() { @@ -694,11 +909,12 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L await MacProcessSessionTerminator.terminate(session, gracePeriod: gracePeriod) } } - guard let processID else { return } - suppressNextTermination = true - if view.process.running { - view.terminate() + let shouldSuppress = processHost.process.running || processHost.process.windingDown + if shouldSuppress { + suppressNextTermination = true + processHost.process.terminate() } + guard let processID else { return } stoppedChildProcessReaper.reapWhenExited(processID) } @@ -759,17 +975,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L .sorted { $0.processID < $1.processID } } - func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} - - func setTerminalTitle(source: LocalProcessTerminalView, title: String) { - onTitle?(title) - } - - func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) { - onDirectoryUpdate?(directory) - } - - func processTerminated(source: SwiftTerm.TerminalView, exitCode: Int32?) { + private func handleProcessTerminated(exitCode: Int32?) { // Jobs that outlived the shell stay stoppable from this tab, but only by // identity: the session ID is reusable once they are gone too. if session?.pinMembers() == false { session = nil } diff --git a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift deleted file mode 100644 index 648a8389fd..0000000000 --- a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift +++ /dev/null @@ -1,147 +0,0 @@ -import AppKit -import Testing -@testable import Lithe - -/// Notifications emitted on background changes for programs subscribed to kitty DEC mode 2031. -@Suite("Terminal background change reporting") -struct MacTerminalBackgroundReportingTests { - @Test - func payloadMatchesSwiftTermOsc11AnswerFormat() { - #expect(MacTerminalBackgroundReporting.osc11Payload(for: .white) == "rgb:ffff/ffff/ffff") - #expect(MacTerminalBackgroundReporting.osc11Payload(for: .black) == "rgb:0000/0000/0000") - // SwiftTerm converts via UInt16(component * 65535), so Lithe's dark editor #191A1C reports 1919/1a1a/1c1c. - let editorDark = NSColor(srgbRed: 25 / 255, green: 26 / 255, blue: 28 / 255, alpha: 1) - #expect(MacTerminalBackgroundReporting.osc11Payload(for: editorDark) == "rgb:1919/1a1a/1c1c") - } - - @Test - func wallpaperTransparencyIsReportedAsBlack() { - let transparentBlack = NSColor.black.withAlphaComponent(0) - #expect(MacTerminalBackgroundReporting.osc11Payload(for: transparentBlack) == "rgb:0000/0000/0000") - } - - @Test - func doesNotNotifyWhenUnsubscribed() { - #expect(MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: false) == nil) - } - - @Test - func changeNotificationReportsLightStateForLightBackground() { - let notification = MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: true) - #expect(notification == "\u{1b}]11;rgb:ffff/ffff/ffff\u{1b}\\\u{1b}[?997;2n") - } - - @Test - func changeNotificationReportsDarkStateForDarkBackground() { - let notification = MacTerminalBackgroundReporting.changeNotification(for: .black, isSubscribed: true) - #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") - } - - @Test - func changeNotificationReportsDarkStateForWallpaperTransparency() { - let transparentBlack = NSColor.black.withAlphaComponent(0) - let notification = MacTerminalBackgroundReporting.changeNotification(for: transparentBlack, isSubscribed: true) - #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") - } -} - -@Suite("Kitty background-change subscription") -struct MacTerminalBackgroundSubscriptionTests { - @Test - func startsUnsubscribed() { - #expect(!MacTerminalBackgroundSubscription().isSubscribed) - } - - @Test - func subscribesOnDecMode2031Set() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - #expect(subscription.isSubscribed) - } - - @Test - func subscribesWhenMode2031IsCombinedWithOtherDecModes() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?1;2031h".utf8) - #expect(subscription.isSubscribed) - } - - @Test - func doesNotSubscribeToModeWithSimilarNumber() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?20310h".utf8) - #expect(!subscription.isSubscribed) - } - - @Test - func unsubscribesOnDecMode2031Reset() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.feed("\u{1b}[?2031l".utf8) - #expect(!subscription.isSubscribed) - } - - @Test - func detectsMarkerSplitAcrossChunks() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?20".utf8) - #expect(!subscription.isSubscribed) - subscription.feed("31h".utf8) - #expect(subscription.isSubscribed) - } - - @Test - func terminalResetClearsSubscription() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.feed("\u{1b}c".utf8) - #expect(!subscription.isSubscribed) - } - - @Test - func processRestartClearsSubscription() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.reset() - #expect(!subscription.isSubscribed) - } - - @Test - func altScreen1049ExitClearsSubscription() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.feed("\u{1b}[?1049l".utf8) - #expect(!subscription.isSubscribed) - } - - @Test - func altScreen47ExitClearsSubscription() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.feed("\u{1b}[?47l".utf8) - #expect(!subscription.isSubscribed) - } - - @Test - func altScreen1047ExitClearsSubscription() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.feed("\u{1b}[?1047l".utf8) - #expect(!subscription.isSubscribed) - } - - @Test - func altScreenEnterKeepsSubscription() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("\u{1b}[?2031h".utf8) - subscription.feed("\u{1b}[?1049h".utf8) - #expect(subscription.isSubscribed) - } - - @Test - func plainOutputStaysUnsubscribed() { - var subscription = MacTerminalBackgroundSubscription() - subscription.feed("ls -la\r\nhello world".utf8) - #expect(!subscription.isSubscribed) - } -} diff --git a/macos/Tests/LitheTests/MacTerminalPTYTests.swift b/macos/Tests/LitheTests/MacTerminalPTYTests.swift new file mode 100644 index 0000000000..f086c38d68 --- /dev/null +++ b/macos/Tests/LitheTests/MacTerminalPTYTests.swift @@ -0,0 +1,74 @@ +import Foundation +@testable import Lithe +import LitheTerminalModule +import Testing + +/// Integration coverage for the owned PTY host introduced for SwiftTerm 2.x. +/// Input must reach the child from both the transport and the terminal's delegate path. +/// Raw output must still be mirrored to `onOutput`. +/// A restart must replace the child while the surface stays usable. +@MainActor +@Suite("Mac terminal PTY transport (integration)") +struct MacTerminalPTYTests { + @Test + func catEchoesInputAndForwardsRawOutput() async throws { + let transport = MacTerminalTransport() + defer { transport.stop() } + + var output = Data() + transport.onOutput = { chunk in + output.append(chunk) + } + var terminationCodes: [Int32?] = [] + transport.onTermination = { terminationCodes.append($0) } + + let directory = NSTemporaryDirectory() + let launch = TerminalProcessLaunch( + title: nil, + executablePath: "/bin/cat", + arguments: [], + workingDirectory: directory, + ) + let firstProcessID = try transport.startProcess(launch, environment: transport.defaultEnvironment()) + #expect(firstProcessID > 0) + #expect(transport.isRunning) + try transport.send(Data("lithe-pty-ok\n".utf8)) + let view = transport.view + view.terminalDelegate?.send(source: view, data: ArraySlice("lithe-view-route-ok\n".utf8)) + + let clock = ContinuousClock() + func received(_ marker: String) -> Bool { + String(decoding: output, as: UTF8.self).contains(marker) + } + let deadline = clock.now.advanced(by: .seconds(3)) + while !received("lithe-pty-ok") || !received("lithe-view-route-ok"), clock.now < deadline { + await Task.yield() + } + + #expect(received("lithe-pty-ok"), "PTY output missing transport marker: \(String(decoding: output, as: UTF8.self))") + #expect(received("lithe-view-route-ok"), "PTY output missing view route marker: \(String(decoding: output, as: UTF8.self))") + + // Restarting the owned PTY must replace the child while the surface stays attached to the same transport. + let secondProcessID = try transport.startProcess(launch, environment: transport.defaultEnvironment()) + #expect(secondProcessID > 0) + #expect(secondProcessID != firstProcessID) + #expect(transport.isRunning) + try transport.send(Data("lithe-restart-ok\n".utf8)) + + let restartDeadline = clock.now.advanced(by: .seconds(3)) + while !received("lithe-restart-ok"), clock.now < restartDeadline { + await Task.yield() + } + #expect(received("lithe-restart-ok"), "PTY output missing restart marker: \(String(decoding: output, as: UTF8.self))") + // The retired child's termination callback reaches the main queue only after the process is gone. + // Bound the wait instead of assuming order. + let retiredDeadline = clock.now.advanced(by: .seconds(3)) + while MacProcessTable.info(processID: firstProcessID) != nil, clock.now < retiredDeadline { + await Task.yield() + } + await Task.yield() + #expect(terminationCodes.isEmpty, + "Restart must suppress the retired child's termination: \(terminationCodes)") + await transport.stopAndWait() + } +} diff --git a/macos/Tests/LitheTests/TerminalInputFocusTests.swift b/macos/Tests/LitheTests/TerminalInputFocusTests.swift index a5970482fb..c809856b67 100644 --- a/macos/Tests/LitheTests/TerminalInputFocusTests.swift +++ b/macos/Tests/LitheTests/TerminalInputFocusTests.swift @@ -43,12 +43,10 @@ struct TerminalInputFocusTests { let terminal = LitheTerminalView(frame: .zero) terminal.applyThemeColors(theme: .lithe, isDark: true) let litheBackground = terminal.nativeBackgroundColor - let litheTerminalBackground = terminal.terminal.backgroundColor terminal.applyThemeColors(theme: .codex, isDark: true) #expect(terminal.nativeBackgroundColor != litheBackground) - #expect(terminal.terminal.backgroundColor != litheTerminalBackground) #expect(terminal.nativeBackgroundColor == LitheTheme.nsColor(.editor, theme: .codex, isDark: true)) } @@ -144,7 +142,7 @@ struct TerminalInputFocusTests { } @Test - func coreGraphicsCaretIsRemovedOutsideKeyboardOwner() throws { + func `core graphics focus changes preserve shell cursor state`() throws { let window = CursorFocusTestWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 250), styleMask: [.borderless], backing: .buffered, defer: false @@ -157,76 +155,83 @@ struct TerminalInputFocusTests { content.addSubview(terminal) content.addSubview(editor) try terminal.setUseMetal(false) + terminal.setCursorStyle(.steadyBar) + terminal.applyThemeColors(theme: .lithe, isDark: true) + // `caretColor` is not open, so Lithe records the requested theme caret in `applyThemeColors` and publishes it only while focused. + let requestedCaret = NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1) #expect(window.makeFirstResponder(terminal)) - terminal.showCursor(source: terminal.terminal) - let focusedChildren = Set(terminal.subviews.map(ObjectIdentifier.init)) - - #expect(window.makeFirstResponder(editor)) - let inactiveChildren = Set(terminal.subviews.map(ObjectIdentifier.init)) - #expect(focusedChildren.subtracting(inactiveChildren).count == 1, - "The fallback caret must be removed, not left as an inactive outline") - #expect(window.makeFirstResponder(terminal)) - #expect(Set(terminal.subviews.map(ObjectIdentifier.init)) == focusedChildren) + #expect(terminal.hasFocus) + #expect(terminal.terminalStateSnapshot().cursorStyle == .steadyBar) + #expect(terminal.caretColor == requestedCaret) - terminal.terminal.hideCursor() + // Lithe publishes the theme caret only while this view owns the keyboard. + // The shell's requested cursor style must remain untouched. #expect(window.makeFirstResponder(editor)) + #expect(!terminal.hasFocus) + #expect(terminal.terminalStateSnapshot().cursorStyle == .steadyBar) + #expect(terminal.caretColor.alphaComponent == 0) #expect(window.makeFirstResponder(terminal)) - #expect(Set(terminal.subviews.map(ObjectIdentifier.init)) == inactiveChildren, - "Focus must not reveal a cursor hidden by the shell") + #expect(terminal.hasFocus) + #expect(terminal.terminalStateSnapshot().cursorStyle == .steadyBar) + #expect(terminal.caretColor == requestedCaret) } @Test - func onlyKeyboardOwnerDrawsVisibleMetalCursorAndPreservesShellStyle() throws { + func `only keyboard owner shows visible caret`() throws { let window = CursorFocusTestWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 250), styleMask: [.borderless], backing: .buffered, defer: false ) window.isReleasedWhenClosed = false defer { window.close() } + let content = try #require(window.contentView) let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) let editor = TerminalOtherResponderView(frame: NSRect(x: 0, y: 200, width: 400, height: 50)) - let content = try #require(window.contentView) content.addSubview(terminal) content.addSubview(editor) - let view = MTKView(frame: terminal.bounds, device: nil) - let renderer = CursorDrawingRecorder() - let focusDelegate = TerminalMetalFocusDelegate(view: view, terminalView: terminal, renderer: renderer) - let requestedColor = NSColor.systemBlue - terminal.caretColor = requestedColor - - for requestedStyle in [CursorStyle.blinkBlock, .blinkBar, .blinkUnderline, .steadyBar] { - terminal.terminal.setCursorStyle(requestedStyle) - #expect(window.makeFirstResponder(editor)) - // Even a stale terminal flag must not animate a background panel. - terminal.hasFocus = true - #expect(!terminal.hasFocus) - renderer.onDraw = { - #expect(terminal.terminal.options.cursorStyle == .steadyBlock) - #expect(terminal.caretColor.alphaComponent == 0) - } - focusDelegate.draw(in: view) - #expect(terminal.terminal.options.cursorStyle == requestedStyle) - #expect(terminal.caretColor == requestedColor) - - #expect(window.makeFirstResponder(terminal)) - #expect(terminal.hasFocus) - renderer.onDraw = { - #expect(terminal.terminal.options.cursorStyle == requestedStyle) - #expect(terminal.caretColor == requestedColor) - } - focusDelegate.draw(in: view) - } - #expect(renderer.drawCount == 8) - let resized = CGSize(width: 640, height: 320) - focusDelegate.mtkView(view, drawableSizeWillChange: resized) - #expect(renderer.drawableSize == resized) + terminal.applyThemeColors(theme: .lithe, isDark: true) + // Lithe records the requested caret from the theme and publishes it only while this view owns the keyboard. + let requestedColor = NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1) + + #expect(window.makeFirstResponder(terminal)) + #expect(terminal.hasFocus) + #expect(terminal.caretColor == requestedColor) + + #expect(window.makeFirstResponder(editor)) + #expect(!terminal.hasFocus) + #expect(terminal.caretColor.alphaComponent == 0, + "A background terminal must not paint its caret") + + #expect(window.makeFirstResponder(terminal)) + #expect(terminal.hasFocus) + #expect(terminal.caretColor == requestedColor) + + // OSC 12 sets the caret color directly. + // The host keeps that color across focus changes instead of falling back to the theme color. + terminal.caretColor = .systemRed + #expect(window.makeFirstResponder(editor)) + #expect(terminal.caretColor.alphaComponent == 0) + #expect(window.makeFirstResponder(terminal)) + #expect(terminal.hasFocus) + #expect(terminal.caretColor == .systemRed, + "An OSC 12 caret color must survive the focus round trip") + + // An OSC 12 write while unfocused must not paint a background caret. + // Its color must survive until this view is focused again. + #expect(window.makeFirstResponder(editor)) + terminal.caretColor = .systemGreen + terminal.refreshCaretFocus() + #expect(terminal.caretColor.alphaComponent == 0) + #expect(window.makeFirstResponder(terminal)) + #expect(terminal.hasFocus) + #expect(terminal.caretColor == .systemGreen, + "An OSC 12 caret color set while unfocused must be restored") + window.reportsKeyWindow = false - renderer.onDraw = { - #expect(terminal.terminal.options.cursorStyle == .steadyBlock) - #expect(terminal.caretColor.alphaComponent == 0) - } - focusDelegate.draw(in: view) + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) #expect(!terminal.hasFocus) + #expect(terminal.caretColor.alphaComponent == 0, + "Resigning key must clear the caret while this view is still the responder") } @Test @@ -359,12 +364,11 @@ struct TerminalCanvasCompositingTests { #expect(clearColor?.blueComponent == 0) #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) - let renderer = MTKView(frame: terminal.bounds, device: nil) - terminal.addSubview(renderer) terminal.applyThemeColors(theme: .lithe, isDark: false) - #expect(renderer.layer?.isOpaque == false) + #expect(terminal.layer?.isOpaque == false) + #expect(terminal.nativeBackgroundColor.alphaComponent == 0) terminal.hasFocus = false - #expect(renderer.layer?.isOpaque == false) + #expect(terminal.layer?.isOpaque == false) } @Test @@ -378,30 +382,48 @@ struct TerminalCanvasCompositingTests { #expect(terminal.nativeBackgroundColor.isEqual(editorColor)) #expect(terminal.layer?.backgroundColor == editorColor.cgColor) #expect(terminal.layer?.isOpaque == true) - let renderer = MTKView(frame: terminal.bounds, device: nil) - terminal.addSubview(renderer) terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) - #expect(renderer.layer?.isOpaque == true) + #expect(terminal.layer?.isOpaque == true) + #expect(terminal.nativeBackgroundColor.alphaComponent == 1) } } + @Test + func `attaching to window enables metal renderer`() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 250), + styleMask: [.borderless], backing: .buffered, defer: false, + ) + window.isReleasedWhenClosed = false + defer { window.close() } + let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) + window.contentView?.addSubview(terminal) + + guard !terminal.isUsingMetalRenderer else { return } + // Staying on Core Graphics is only acceptable when this host genuinely cannot build the Metal device or shader pipeline. + // Retrying the renderer distinguishes that from a regression in the attachment path. + do { + try terminal.setUseMetal(true) + } catch { + #expect(!terminal.isUsingMetalRenderer) + return + } + Issue.record("Attaching the terminal to a window did not enable the Metal renderer") + } + @Test func canvasFollowsWorkbenchBackgroundChanges() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) terminal.applyThemeColors(theme: .lithe, isDark: false) - let renderer = MTKView(frame: terminal.bounds, device: nil) - terminal.addSubview(renderer) terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) #expect(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)?.redComponent == 0) #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) - #expect(renderer.layer?.isOpaque == false) terminal.updateWorkbenchSurface(isVisible: false, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.layer?.isOpaque == true) - #expect(renderer.layer?.isOpaque == true) } @Test @@ -411,15 +433,17 @@ struct TerminalCanvasCompositingTests { terminal.applyThemeColors(theme: .lithe, isDark: false) let editorColor = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false).usingColorSpace(.sRGB) let expected = try #require(editorColor) - let reported = terminal.terminal.backgroundColor - #expect(reported.red == UInt16((expected.redComponent * 65535).rounded())) - #expect(reported.green == UInt16((expected.greenComponent * 65535).rounded())) - #expect(reported.blue == UInt16((expected.blueComponent * 65535).rounded())) + let reported = try #require(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)) + #expect(reported.redComponent == expected.redComponent) + #expect(reported.greenComponent == expected.greenComponent) + #expect(reported.blueComponent == expected.blueComponent) + #expect(reported.alphaComponent == 1) terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) - let transparent = terminal.terminal.backgroundColor - #expect(transparent.red == 0) - #expect(transparent.green == 0) - #expect(transparent.blue == 0) + let transparent = try #require(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)) + #expect(transparent.redComponent == 0) + #expect(transparent.greenComponent == 0) + #expect(transparent.blueComponent == 0) + #expect(transparent.alphaComponent == 0) } @Test @@ -432,6 +456,18 @@ struct TerminalCanvasCompositingTests { try await assertMountedTerminalThemeRefresh(scheme: .light) } + /// SwiftTerm 1.99 round-trips native colors through its 16-bit terminal color model. + /// The view's native colors therefore differ from the theme colors by encoding error. + /// Compare components instead of using `isEqual`. + private func colorsMatch(_ color: NSColor, _ expected: NSColor, tolerance: CGFloat = 0.001) -> Bool { + guard let actual = color.usingColorSpace(.sRGB), + let target = expected.usingColorSpace(.sRGB) else { return false } + return abs(actual.redComponent - target.redComponent) <= tolerance + && abs(actual.greenComponent - target.greenComponent) <= tolerance + && abs(actual.blueComponent - target.blueComponent) <= tolerance + && abs(actual.alphaComponent - target.alphaComponent) <= tolerance + } + private func assertMountedTerminalThemeRefresh(scheme: ColorScheme) async throws { let domain = "Lithe.TerminalThemeRefresh.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: domain)) @@ -461,14 +497,15 @@ struct TerminalCanvasCompositingTests { let initialBackground = LitheTheme.nsColor(.editor, theme: .lithe, isDark: isDark) let initialForeground = LitheTheme.nsColor(.primaryText, theme: .lithe, isDark: isDark) let initialDeadline = ContinuousClock().now.advanced(by: .seconds(2)) - while (!terminal.nativeBackgroundColor.isEqual(initialBackground) - || !terminal.nativeForegroundColor.isEqual(initialForeground)), - ContinuousClock().now < initialDeadline { + while !colorsMatch(terminal.nativeBackgroundColor, initialBackground) + || !colorsMatch(terminal.nativeForegroundColor, initialForeground), + ContinuousClock().now < initialDeadline + { host.layoutSubtreeIfNeeded() await Task.yield() } - #expect(terminal.nativeBackgroundColor.isEqual(initialBackground)) - #expect(terminal.nativeForegroundColor.isEqual(initialForeground)) + #expect(colorsMatch(terminal.nativeBackgroundColor, initialBackground)) + #expect(colorsMatch(terminal.nativeForegroundColor, initialForeground)) #expect(session.isRunning) let clock = ContinuousClock() for theme in [AppColorTheme.codex, .linear, .lithe] { @@ -476,24 +513,25 @@ struct TerminalCanvasCompositingTests { let expected = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark) let expectedForeground = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) let deadline = clock.now.advanced(by: .seconds(2)) - while (!terminal.nativeBackgroundColor.isEqual(expected) - || !terminal.nativeForegroundColor.isEqual(expectedForeground)), - clock.now < deadline { + while !colorsMatch(terminal.nativeBackgroundColor, expected) + || !colorsMatch(terminal.nativeForegroundColor, expectedForeground), + clock.now < deadline + { host.layoutSubtreeIfNeeded() await Task.yield() } - let sRGB = try #require(expected.usingColorSpace(.sRGB)) - let reported = terminal.terminal.backgroundColor - let reportedForeground = terminal.terminal.foregroundColor + let expectedSRGB = try #require(expected.usingColorSpace(.sRGB)) let expectedForegroundSRGB = try #require(expectedForeground.usingColorSpace(.sRGB)) - #expect(terminal.nativeBackgroundColor.isEqual(expected)) - #expect(terminal.nativeForegroundColor.isEqual(expectedForeground)) - #expect(reported.red == UInt16(sRGB.redComponent * 65535)) - #expect(reported.green == UInt16(sRGB.greenComponent * 65535)) - #expect(reported.blue == UInt16(sRGB.blueComponent * 65535)) - #expect(abs(Int(reportedForeground.red) - Int(expectedForegroundSRGB.redComponent * 65535)) <= 1) - #expect(abs(Int(reportedForeground.green) - Int(expectedForegroundSRGB.greenComponent * 65535)) <= 1) - #expect(abs(Int(reportedForeground.blue) - Int(expectedForegroundSRGB.blueComponent * 65535)) <= 1) + let reported = try #require(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)) + let reportedForeground = try #require(terminal.nativeForegroundColor.usingColorSpace(.sRGB)) + #expect(colorsMatch(terminal.nativeBackgroundColor, expected)) + #expect(colorsMatch(terminal.nativeForegroundColor, expectedForeground)) + #expect(abs(reported.redComponent - expectedSRGB.redComponent) <= 0.001) + #expect(abs(reported.greenComponent - expectedSRGB.greenComponent) <= 0.001) + #expect(abs(reported.blueComponent - expectedSRGB.blueComponent) <= 0.001) + #expect(abs(reportedForeground.redComponent - expectedForegroundSRGB.redComponent) <= 0.001) + #expect(abs(reportedForeground.greenComponent - expectedForegroundSRGB.greenComponent) <= 0.001) + #expect(abs(reportedForeground.blueComponent - expectedForegroundSRGB.blueComponent) <= 0.001) #expect(session.isRunning) } session.stop() @@ -542,28 +580,6 @@ private final class CursorInvalidationRecordingView: MTKView { } } -// Tests invoke these delegate callbacks synchronously on the main actor. -// Older SDKs expose MTKViewDelegate without actor isolation. -@MainActor -private final class CursorDrawingRecorder: NSObject, MTKViewDelegate { - var onDraw: () -> Void = {} - var drawCount = 0 - var drawableSize: CGSize? - - nonisolated func draw(in view: MTKView) { - MainActor.assumeIsolated { - drawCount += 1 - onDraw() - } - } - - nonisolated func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { - MainActor.assumeIsolated { - drawableSize = size - } - } -} - /// The terminal must release its caret for any other keyboard owner, regardless /// of which editor renderer the application embeds. @MainActor diff --git a/macos/Tests/LitheTests/TerminalScrollbackSettingsTests.swift b/macos/Tests/LitheTests/TerminalScrollbackSettingsTests.swift index ddf537413c..fde48af951 100644 --- a/macos/Tests/LitheTests/TerminalScrollbackSettingsTests.swift +++ b/macos/Tests/LitheTests/TerminalScrollbackSettingsTests.swift @@ -2,9 +2,8 @@ import Foundation import Testing @testable import Lithe -/// Covers the stored terminal scrollback setting and the transport hand-off: -/// default, clamping, persistence, reset, and the value the PTY-backed terminal -/// is constructed with. +/// Covers the stored terminal scrollback setting: default, clamping, +/// persistence, and reset. @MainActor @Suite("Terminal scrollback setting") struct TerminalScrollbackSettingsTests { @@ -39,20 +38,6 @@ struct TerminalScrollbackSettingsTests { store.set(999_999, forKey: "settings.terminalScrollbackLines") #expect(AppSettings(store: store).terminalScrollbackLines == AppSettings.terminalScrollbackLinesRange.upperBound) } - - @Test - func transportUsesConfiguredScrollback() { - let terminal = MacTerminalTransport(scrollbackLines: 8_000) - defer { terminal.stop() } - #expect(terminal.view.terminal.options.scrollback == 8_000) - } - - @Test - func transportDefaultsToTheStoredDefault() { - let terminal = MacTerminalTransport() - defer { terminal.stop() } - #expect(terminal.view.terminal.options.scrollback == AppSettings.defaultTerminalScrollbackLines) - } } /// Isolated defaults store so settings tests never read or write the diff --git a/scripts/measure-macos-performance-baseline.sh b/scripts/measure-macos-performance-baseline.sh index 5241ec2c03..044367de5f 100755 --- a/scripts/measure-macos-performance-baseline.sh +++ b/scripts/measure-macos-performance-baseline.sh @@ -102,8 +102,6 @@ prepare_baseline_app() { zsh "$project_root/scripts/embed-sparkle.sh" "$app_dir" cp -R "$build_root/Lithe_Lithe.bundle" \ "$app_dir/Contents/Resources/Lithe_Lithe.bundle" - cp -R "$build_root/SwiftTerm_SwiftTerm.bundle" \ - "$app_dir/Contents/Resources/SwiftTerm_SwiftTerm.bundle" cp "$project_root/macos/Resources/AppIcon.icns" "$app_dir/Contents/Resources/AppIcon.icns" cp -R "$project_root/macos/Resources/Fonts" "$app_dir/Contents/Resources/Fonts" cp -R "$project_root/.artifacts/editor/macos" "$app_dir/Contents/Resources/MonacoEditor" diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 96a93eac57..c4b1df0f9f 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -73,9 +73,9 @@ if [[ "$ARCH" == "universal" ]]; then else swiftpm_resource_root="$ROOT_DIR/.build/$ARCH-apple-macosx/release" fi -# SwiftTerm compiles its Metal shaders from this SwiftPM bundle at runtime. -# Without it, setUseMetal(true) falls back to Core Graphics in packaged apps. -for bundle_name in Lithe_Lithe.bundle SwiftTerm_SwiftTerm.bundle; do +# SwiftTerm 1.99+ embeds its Metal shaders in source, so only Lithe's own +# SwiftPM bundle needs packaging; without it Bundle.module reads fail at runtime. +for bundle_name in Lithe_Lithe.bundle; do resource_bundle="$swiftpm_resource_root/$bundle_name" if [[ ! -d "$resource_bundle" ]]; then print -u2 -- "Missing SwiftPM resource bundle: $resource_bundle" diff --git a/scripts/preview.sh b/scripts/preview.sh index c8bafe9796..fcb1fe3a40 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -34,7 +34,7 @@ mkdir -p \ "$APP_DIR/Contents/Resources/OfficialPlugins" \ "$APP_DIR/Contents/Helpers" cp ".build/$TRIPLE/debug/Lithe" "$APP_DIR/Contents/MacOS/Lithe" -for bundle_name in Lithe_Lithe.bundle SwiftTerm_SwiftTerm.bundle; do +for bundle_name in Lithe_Lithe.bundle; do resource_bundle=".build/$TRIPLE/debug/$bundle_name" if [[ ! -d "$resource_bundle" ]]; then print -u2 -- "Missing SwiftPM resource bundle: $resource_bundle" diff --git a/scripts/verify-macos-app-build-safety.sh b/scripts/verify-macos-app-build-safety.sh index 5ab4b8a731..a474a1e71e 100755 --- a/scripts/verify-macos-app-build-safety.sh +++ b/scripts/verify-macos-app-build-safety.sh @@ -39,10 +39,6 @@ for packaging_script in scripts/package-app.sh scripts/preview.sh; do print -u2 -- "$packaging_script must package Lithe's SwiftPM resources" exit 1 fi - if ! /usr/bin/grep -Fq -- 'SwiftTerm_SwiftTerm.bundle' "$packaging_script"; then - print -u2 -- "$packaging_script must package SwiftTerm's Metal shader resources" - exit 1 - fi done temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/lithe-build-info-verification.XXXXXX") diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 9576163936..9d112ec320 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -194,7 +194,6 @@ required_resources=( "$app_path/Contents/Resources/Fonts/OFL.txt" "$app_path/Contents/Resources/Fonts/AUTHORS.txt" "$app_path/Contents/Resources/Fonts/NOTICE.txt" - "$app_path/Contents/Resources/SwiftTerm_SwiftTerm.bundle/Shaders.metal" "$app_path/Contents/Resources/MonacoEditor/index.html" "$app_path/Contents/Resources/MonacoEditor/workbench.js" "$app_path/Contents/Resources/MonacoEditor/workbench.css" diff --git a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json index 40986c2926..7761e34db1 100644 --- a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json +++ b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json @@ -2,15 +2,14 @@ "id": "terminal-canvas-compositing", "area": "工作台", "group": "终端", - "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景变化时仅向订阅 kitty DEC mode 2031 的终端应用推送 OSC 11 与 DSR 997,997;1n 报暗、997;2n 报亮", + "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景变化时仅向订阅 kitty DEC mode 2031 的终端应用推送 DSR 997,997;1n 报暗、997;2n 报亮;应用收到通知后自行查询 OSC 10/11", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", "macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift", - "macos/Tests/LitheTests/TerminalInputFocusTests.swift", - "macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift" + "macos/Tests/LitheTests/TerminalInputFocusTests.swift" ] }, "windows": { @@ -21,5 +20,5 @@ ] }, "owner": "Terminal", - "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,仅已运行且订阅 kitty DEC mode 2031 的 TUI(如 oh-my-pi)应收到背景变化通知并无需重启即跟随重查;未订阅的 zsh/cat 切换时不得收到回显字节;验证订阅、退订、跨输出块切分及终端 RIS 重置;DSR 997 暗色发送 997;1n、亮色发送 997;2n。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;有壁纸时 COLORFGBG 导出深色 15;0,与 OSC 11 黑色应答和 DSR 997;1n 同源一致;若上游改为预乘清屏可恢复报告编辑器底色。" + "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,仅已运行且订阅 kitty DEC mode 2031 的 TUI(如 oh-my-pi)应收到 DSR 997 通知并自行重查 OSC 10/11,无需重启即跟随;未订阅的 zsh/cat 切换时不得收到回显字节;验证订阅、退订、跨输出块切分及终端 RIS 重置;DSR 997 暗色发送 997;1n、亮色发送 997;2n。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;有壁纸时 COLORFGBG 导出深色 15;0,与 OSC 11 黑色应答和 DSR 997;1n 同源一致;若上游改为预乘清屏可恢复报告编辑器底色。" }