feat: add terminal notificaitons
This commit is contained in:
1 parent
b6ff9fd7d1
commit
e2452df929
37 files changed
+1263
-7
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPersistence
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct TerminalCommandMonitorTests {
|
||||
private let available = SSHShellIntegrationEvent.available(shell: "zsh", systemInfo: nil, homeDirectory: nil)
|
||||
|
||||
@Test func notificationsDefaultOffButEveryBackgroundCompletionMarksUnread() {
|
||||
let monitor = TerminalCommandMonitor()
|
||||
var alerts = 0
|
||||
monitor.alert = { _, _ in alerts += 1 }
|
||||
monitor.beginConnection()
|
||||
monitor.receiveOSC133(exitCode: 1, durationNanos: 1)
|
||||
#expect(monitor.hasUnreadCompletion)
|
||||
#expect(alerts == 0)
|
||||
monitor.markRead()
|
||||
#expect(!monitor.hasUnreadCompletion)
|
||||
monitor.receiveOSC133(exitCode: nil, durationNanos: 0)
|
||||
#expect(monitor.hasUnreadCompletion)
|
||||
}
|
||||
|
||||
@Test(arguments: CommandNotificationPreferences.Mode.allCases)
|
||||
func modeAndThresholdUseFocusAtCompletion(_ mode: CommandNotificationPreferences.Mode) {
|
||||
let monitor = TerminalCommandMonitor()
|
||||
var settings = CommandNotificationPreferences()
|
||||
settings.mode = mode
|
||||
var focused = false
|
||||
var completions: [TerminalCommandCompletion] = []
|
||||
monitor.preferences = { settings }
|
||||
monitor.isWorkspaceFocused = { focused }
|
||||
monitor.alert = { completion, _ in completions.append(completion) }
|
||||
monitor.beginConnection()
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 4_999_999_999)
|
||||
#expect(completions.isEmpty)
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 5_000_000_000)
|
||||
#expect(completions.count == (mode == .never ? 0 : 1))
|
||||
focused = true
|
||||
monitor.receiveOSC133(exitCode: nil, durationNanos: 6_000_000_000)
|
||||
#expect(!monitor.hasUnreadCompletion)
|
||||
#expect(completions.count == (mode == .always ? 2 : mode == .unfocused ? 1 : 0))
|
||||
}
|
||||
|
||||
@Test func privateIntegrationWinsAndDuplicateFinishesAreIgnoredWithoutHistory() {
|
||||
var clock: TimeInterval = 100
|
||||
let monitor = TerminalCommandMonitor(now: { clock })
|
||||
var settings = CommandNotificationPreferences()
|
||||
settings.mode = .always
|
||||
monitor.preferences = { settings }
|
||||
var completions: [TerminalCommandCompletion] = []
|
||||
monitor.alert = { event, _ in completions.append(event) }
|
||||
monitor.beginConnection()
|
||||
monitor.receiveShellEvent(available)
|
||||
monitor.receiveShellEvent(.commandStarted("sleep 9"))
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 9_000_000_000)
|
||||
clock = 109
|
||||
monitor.receiveShellEvent(.commandFinished(2, duration: nil))
|
||||
monitor.receiveShellEvent(.commandFinished(2, duration: nil))
|
||||
#expect(completions == [.init(command: "sleep 9", exitCode: 2, duration: 9)])
|
||||
monitor.receiveShellEvent(.commandStarted("sleep 7"))
|
||||
clock = 120
|
||||
monitor.receiveShellEvent(.commandFinished(0, duration: 7))
|
||||
#expect(completions.last?.duration == 7)
|
||||
}
|
||||
|
||||
@Test func disconnectDiscardsActiveCommandAndReconnectReselectsEventSource() {
|
||||
let monitor = TerminalCommandMonitor()
|
||||
monitor.beginConnection()
|
||||
monitor.receiveShellEvent(available)
|
||||
monitor.receiveShellEvent(.commandStarted("old command"))
|
||||
monitor.endConnection()
|
||||
monitor.receiveShellEvent(.commandFinished(0, duration: 20))
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 20_000_000_000)
|
||||
#expect(!monitor.hasUnreadCompletion)
|
||||
monitor.beginConnection()
|
||||
monitor.receiveShellEvent(.commandFinished(0, duration: 20))
|
||||
#expect(!monitor.hasUnreadCompletion)
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 1)
|
||||
#expect(monitor.hasUnreadCompletion)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true], [false, true])
|
||||
func notificationAndBellOptionsAreIndependent(notify: Bool, bell: Bool) {
|
||||
let monitor = TerminalCommandMonitor()
|
||||
var settings = CommandNotificationPreferences()
|
||||
settings.mode = .always
|
||||
settings.after = 0
|
||||
settings.notify = notify
|
||||
settings.bell = bell
|
||||
monitor.preferences = { settings }
|
||||
var actions: [CommandNotificationPreferences] = []
|
||||
monitor.alert = { _, value in actions.append(value) }
|
||||
monitor.beginConnection()
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 0)
|
||||
#expect(actions == (notify || bell ? [settings] : []))
|
||||
#expect(monitor.hasUnreadCompletion)
|
||||
}
|
||||
|
||||
@Test func commandPreviewRemovesControlsAndBoundsUnicodeText() {
|
||||
let completion = TerminalCommandCompletion(command: "echo\n\thello\u{1b}\u{0} world", exitCode: 0, duration: 1)
|
||||
#expect(completion.commandPreview == "echo hello world")
|
||||
let long = TerminalCommandCompletion(command: String(repeating: "字", count: 170), exitCode: nil, duration: 0)
|
||||
#expect(long.commandPreview?.count == 160)
|
||||
#expect(long.commandPreview?.last == "…")
|
||||
#expect(TerminalCommandCompletion(exitCode: nil, duration: 0).commandPreview == nil)
|
||||
}
|
||||
|
||||
@Test func preferencesRoundTripAndOldPayloadsUseDefaults() throws {
|
||||
var preferences = TerminalPreferences()
|
||||
preferences.commandNotifications.mode = .unfocused
|
||||
preferences.commandNotifications.after = 10.5
|
||||
preferences.commandNotifications.bell = true
|
||||
let data = try JSONEncoder().encode(preferences)
|
||||
#expect(try JSONDecoder().decode(TerminalPreferences.self, from: data) == preferences)
|
||||
let row = try PersistentTerminalPreferences(preferences)
|
||||
#expect(try row.load() == preferences)
|
||||
let domain = "CommandPreferences.\(UUID())"
|
||||
let defaults = try #require(UserDefaults(suiteName: domain))
|
||||
defer { defaults.removePersistentDomain(forName: domain) }
|
||||
preferences.mirror(to: defaults)
|
||||
#expect(TerminalPreferences(defaults: defaults) == preferences)
|
||||
let payload = SettingsRecordPayload.terminal(preferences)
|
||||
#expect(try JSONDecoder().decode(SettingsRecordPayload.self, from: JSONEncoder().encode(payload)) == payload)
|
||||
var old = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
old.removeValue(forKey: "commandNotifications")
|
||||
let decoded = try JSONDecoder().decode(
|
||||
TerminalPreferences.self, from: JSONSerialization.data(withJSONObject: old))
|
||||
#expect(decoded.commandNotifications == CommandNotificationPreferences())
|
||||
}
|
||||
}
|
||||
@@ -3074,6 +3074,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Always" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "总是"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "一律"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Always Ask" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -5847,6 +5864,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Command Completion" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "命令完成"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "指令完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Command Generator" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -5983,6 +6017,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Command completed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "命令已完成"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "指令已完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Commands" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -6159,6 +6210,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Completed · %@" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已完成·%@"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已完成·%@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Concurrent Strategy" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -10881,6 +10949,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Exit code %lld · %@" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "退出码%lld·%@"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "結束代碼%lld·%@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Expand" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -15408,6 +15493,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Minimum Duration (seconds)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最短运行时间(秒)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最短執行時間(秒)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mode" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -17542,6 +17644,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notifications are disabled in system settings." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "系统设置中已关闭通知。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "系統設定中已關閉通知。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notifications can be delayed while iOS suspends Termind." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "iOS挂起Termind时,通知可能延迟。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "iOS暫停Termind時,通知可能延遲。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notify When" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "通知时机"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "通知時機"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"OK" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -17957,6 +18110,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Open Notification Settings" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "打开通知设置"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "打開通知設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Open System Settings" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -21703,6 +21873,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Requires shell integration or OSC 133. Workspace dots also mark shorter commands." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要Shell集成或OSC 133。工作区红点也会标记较短的命令。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要Shell整合或OSC 133。工作區紅點也會標記較短的指令。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Requires user action" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -25579,6 +25766,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Succeeded · %@" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "成功·%@"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "成功·%@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Suggested Command" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -25802,6 +26006,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"System Notification" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "系统通知"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "系統通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tab" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -26046,6 +26267,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Terminal Bell" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "终端铃声"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "終端機鈴聲"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Terminal Input" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -30450,6 +30688,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unread command completion" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "有未读的命令完成消息"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "有未讀的指令完成訊息"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unrecognized additional host key" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -31893,6 +32148,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Workspace Is Unfocused" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "工作区未聚焦时"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "工作區未聚焦時"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Workspace Name" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public nonisolated enum CommandNotificationAuthorization: Sendable {
|
||||
case notDetermined, allowed, denied
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol CommandNotificationSending: AnyObject {
|
||||
func authorization(request: Bool) async -> CommandNotificationAuthorization
|
||||
func send(sessionID: UUID, title: String, body: String) async
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public nonisolated enum SettingsKey {
|
||||
public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
|
||||
public static let settingsSyncEnabled = "settings.sync.enabled"
|
||||
public static let settingsSyncEnvironment = "settings.sync.environment"
|
||||
public static let commandNotifications = "terminal.commandNotifications"
|
||||
public static let shellIntegration = "terminal.shellIntegration"
|
||||
public static let sshMaxSessions = "ssh.maxSessions"
|
||||
public static let defaultSSHMaxSessions = SSHPreferences.defaultMaxSessions
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import TermindDomain
|
||||
|
||||
public nonisolated struct TerminalCommandCompletion: Equatable, Sendable {
|
||||
public var command: String?
|
||||
public var exitCode: Int?
|
||||
public var duration: TimeInterval
|
||||
|
||||
public init(command: String? = nil, exitCode: Int?, duration: TimeInterval) {
|
||||
self.command = command
|
||||
self.exitCode = exitCode
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
public var commandPreview: String? {
|
||||
guard let command else { return nil }
|
||||
let cleaned = command.unicodeScalars.map { scalar in
|
||||
CharacterSet.controlCharacters.contains(scalar) ? " " : String(scalar)
|
||||
}.joined().split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return cleaned.count > 160 ? String(cleaned.prefix(159)) + "…" : cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-terminal state, independent of history and transient view mounting.
|
||||
@MainActor @Observable
|
||||
public final class TerminalCommandMonitor {
|
||||
public private(set) var hasUnreadCompletion = false
|
||||
public var preferences: () -> CommandNotificationPreferences = { .init() }
|
||||
public var isWorkspaceFocused: () -> Bool = { false }
|
||||
public var alert: (TerminalCommandCompletion, CommandNotificationPreferences) -> Void = { _, _ in }
|
||||
private let now: () -> TimeInterval
|
||||
private var active = false
|
||||
private var usesShellIntegration = false
|
||||
private var running: (command: String, started: TimeInterval)?
|
||||
|
||||
public init(now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.now = now
|
||||
}
|
||||
|
||||
public func beginConnection() {
|
||||
active = true
|
||||
usesShellIntegration = false
|
||||
running = nil
|
||||
}
|
||||
|
||||
public func endConnection() {
|
||||
active = false
|
||||
running = nil
|
||||
usesShellIntegration = false
|
||||
}
|
||||
|
||||
public func markRead() { hasUnreadCompletion = false }
|
||||
|
||||
public func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
|
||||
guard active else { return }
|
||||
switch event {
|
||||
case .available:
|
||||
usesShellIntegration = true
|
||||
case .commandStarted(let command):
|
||||
guard usesShellIntegration else { return }
|
||||
running = (command, now())
|
||||
case .commandFinished(let status, let reported):
|
||||
guard usesShellIntegration, let command = running else { return }
|
||||
running = nil
|
||||
let duration =
|
||||
reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
|
||||
?? max(0, now() - command.started)
|
||||
complete(.init(command: command.command, exitCode: Int(status), duration: duration))
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
public func receiveOSC133(exitCode: Int?, durationNanos: UInt64) {
|
||||
guard active, !usesShellIntegration else { return }
|
||||
complete(.init(exitCode: exitCode, duration: Double(durationNanos) / 1_000_000_000))
|
||||
}
|
||||
|
||||
private func complete(_ completion: TerminalCommandCompletion) {
|
||||
let focused = isWorkspaceFocused()
|
||||
hasUnreadCompletion = !focused
|
||||
let settings = preferences()
|
||||
guard settings.mode != .never,
|
||||
settings.mode == .always || !focused,
|
||||
completion.duration >= settings.threshold,
|
||||
settings.notify || settings.bell
|
||||
else { return }
|
||||
alert(completion, settings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
public nonisolated struct CommandNotificationPreferences: Codable, Hashable, Sendable {
|
||||
public enum Mode: String, Codable, CaseIterable, Sendable {
|
||||
case never, unfocused, always
|
||||
}
|
||||
|
||||
public var mode: Mode = .never
|
||||
public var notify = true
|
||||
public var bell = false
|
||||
public var after: TimeInterval = 5
|
||||
|
||||
public init() {}
|
||||
|
||||
public var threshold: TimeInterval { after.isFinite && after >= 0 ? after : 5 }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
|
||||
public var remoteWindowTitleInMenuBar = true
|
||||
public var copyOnSelect = false
|
||||
public var pasteOnRightClick = false
|
||||
public var commandNotifications = CommandNotificationPreferences()
|
||||
public init(
|
||||
darkTheme: String = "Termind Dark",
|
||||
lightTheme: String = "Termind Light", darkCustomEnabled: Bool = false, lightCustomEnabled: Bool = false,
|
||||
@@ -32,7 +33,7 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case darkTheme, lightTheme, darkCustomEnabled, lightCustomEnabled
|
||||
case darkCustomConfiguration, lightCustomConfiguration, shellIntegration, remoteWindowTitleInMenuBar
|
||||
case copyOnSelect, pasteOnRightClick
|
||||
case copyOnSelect, pasteOnRightClick, commandNotifications
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -45,6 +46,8 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
|
||||
lightCustomConfiguration = try values.decode(String.self, forKey: .lightCustomConfiguration)
|
||||
shellIntegration = try values.decode(Bool.self, forKey: .shellIntegration)
|
||||
remoteWindowTitleInMenuBar = try values.decode(Bool.self, forKey: .remoteWindowTitleInMenuBar)
|
||||
commandNotifications =
|
||||
try values.decodeIfPresent(CommandNotificationPreferences.self, forKey: .commandNotifications) ?? .init()
|
||||
// Existing local blobs and Settings sync records predate the mouse preferences.
|
||||
copyOnSelect = try values.decodeIfPresent(Bool.self, forKey: .copyOnSelect) ?? false
|
||||
pasteOnRightClick = try values.decodeIfPresent(Bool.self, forKey: .pasteOnRightClick) ?? false
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
public final class SystemCommandNotifications: NSObject, CommandNotificationSending, UNUserNotificationCenterDelegate {
|
||||
public var onOpenSession: ((UUID) -> Void)? {
|
||||
didSet {
|
||||
if let pendingSession, let onOpenSession {
|
||||
self.pendingSession = nil
|
||||
onOpenSession(pendingSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var pendingSession: UUID?
|
||||
private let center: UNUserNotificationCenter?
|
||||
|
||||
public override init() {
|
||||
center =
|
||||
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil
|
||||
? UNUserNotificationCenter.current() : nil
|
||||
super.init()
|
||||
center?.delegate = self
|
||||
}
|
||||
|
||||
public func authorization(request: Bool = false) async -> CommandNotificationAuthorization {
|
||||
guard let center else { return .denied }
|
||||
var status = await center.notificationSettings().authorizationStatus
|
||||
if request, status == .notDetermined {
|
||||
_ = try? await center.requestAuthorization(options: [.alert])
|
||||
status = await center.notificationSettings().authorizationStatus
|
||||
}
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral: return .allowed
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
public func send(sessionID: UUID, title: String, body: String) async {
|
||||
guard let center, await authorization() == .allowed else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.userInfo = ["commandSessionID": sessionID.uuidString]
|
||||
content.threadIdentifier = sessionID.uuidString
|
||||
// Sound is controlled separately by the terminal bell setting.
|
||||
try? await center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||
}
|
||||
|
||||
public nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter, willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
notification.request.content.userInfo["commandSessionID"] == nil ? [] : [.banner, .list]
|
||||
}
|
||||
|
||||
public nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
guard response.actionIdentifier == UNNotificationDefaultActionIdentifier,
|
||||
let raw = response.notification.request.content.userInfo["commandSessionID"] as? String,
|
||||
let id = UUID(uuidString: raw)
|
||||
else { return }
|
||||
await MainActor.run {
|
||||
if let onOpenSession { onOpenSession(id) } else { pendingSession = id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import TermindDomain
|
||||
extension TerminalPreferences {
|
||||
public nonisolated init(defaults: UserDefaults) {
|
||||
self.init()
|
||||
if let data = defaults.data(forKey: SettingsKey.commandNotifications),
|
||||
let value = try? JSONDecoder().decode(CommandNotificationPreferences.self, from: data)
|
||||
{
|
||||
commandNotifications = value
|
||||
}
|
||||
darkTheme = defaults.string(forKey: SettingsKey.terminalThemeDark) ?? darkTheme
|
||||
lightTheme = defaults.string(forKey: SettingsKey.terminalThemeLight) ?? lightTheme
|
||||
darkCustomEnabled =
|
||||
@@ -23,6 +28,7 @@ extension TerminalPreferences {
|
||||
}
|
||||
|
||||
public nonisolated func mirror(to defaults: UserDefaults) {
|
||||
defaults.set(try? JSONEncoder().encode(commandNotifications), forKey: SettingsKey.commandNotifications)
|
||||
defaults.set(darkTheme, forKey: SettingsKey.terminalThemeDark)
|
||||
defaults.set(lightTheme, forKey: SettingsKey.terminalThemeLight)
|
||||
defaults.set(darkCustomEnabled, forKey: SettingsKey.terminalCustomConfigurationDarkEnabled)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
extension AppStrings {
|
||||
public nonisolated enum CommandNotifications {
|
||||
public static var title: LocalizedStringResource { "Command Completion" }
|
||||
public static var when: LocalizedStringResource { "Notify When" }
|
||||
public static var never: LocalizedStringResource { "Never" }
|
||||
public static var unfocused: LocalizedStringResource { "Workspace Is Unfocused" }
|
||||
public static var always: LocalizedStringResource { "Always" }
|
||||
public static var system: LocalizedStringResource { "System Notification" }
|
||||
public static var bell: LocalizedStringResource { "Terminal Bell" }
|
||||
public static var seconds: LocalizedStringResource { "Minimum Duration (seconds)" }
|
||||
public static var openSettings: LocalizedStringResource { "Open Notification Settings" }
|
||||
public static var denied: LocalizedStringResource { "Notifications are disabled in system settings." }
|
||||
public static var unread: LocalizedStringResource { "Unread command completion" }
|
||||
public static var explanation: LocalizedStringResource {
|
||||
"Requires shell integration or OSC 133. Workspace dots also mark shorter commands."
|
||||
}
|
||||
public static var mobileExplanation: LocalizedStringResource {
|
||||
"Notifications can be delayed while iOS suspends Termind."
|
||||
}
|
||||
public static var completed: LocalizedStringResource { "Command completed" }
|
||||
public static func succeeded(_ duration: String) -> LocalizedStringResource {
|
||||
"Succeeded · \(duration)"
|
||||
}
|
||||
public static func failed(_ code: Int, _ duration: String) -> LocalizedStringResource {
|
||||
"Exit code \(code) · \(duration)"
|
||||
}
|
||||
public static func finished(_ duration: String) -> LocalizedStringResource {
|
||||
"Completed · \(duration)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ nonisolated extension TerminalCustomConfiguration {
|
||||
-> GhosttyTerminal.TerminalConfiguration
|
||||
{
|
||||
GhosttyTerminal.TerminalConfiguration(startingFrom: base) { builder in
|
||||
for entry in entries {
|
||||
// Command alerts are owned by the app's shared settings and dispatcher.
|
||||
for entry in entries where entry.key != "notify-on-command-finish" {
|
||||
builder.withCustom(entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +128,7 @@ extension TerminalViewState {
|
||||
setTheme(GhosttyTerminal.TerminalTheme(light: light, dark: dark))
|
||||
setTerminalConfiguration(
|
||||
GhosttyTerminal.TerminalConfiguration { builder in
|
||||
builder.withCustom("notify-on-command-finish", "never")
|
||||
builder.withWindowPaddingX(8)
|
||||
builder.withWindowPaddingY(8)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
public struct TerminalCommandNotificationFields: View {
|
||||
private let model: TerminalSettingsModel
|
||||
private let service: any CommandNotificationSending
|
||||
private let openSettings: () -> Void
|
||||
@State private var authorization = CommandNotificationAuthorization.notDetermined
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
public init(
|
||||
model: TerminalSettingsModel, service: any CommandNotificationSending, openSettings: @escaping () -> Void
|
||||
) {
|
||||
self.model = model
|
||||
self.service = service
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Section {
|
||||
Picker(AppStrings.CommandNotifications.when, selection: binding(\.mode)) {
|
||||
Text(AppStrings.CommandNotifications.never).tag(CommandNotificationPreferences.Mode.never)
|
||||
Text(AppStrings.CommandNotifications.unfocused).tag(CommandNotificationPreferences.Mode.unfocused)
|
||||
Text(AppStrings.CommandNotifications.always).tag(CommandNotificationPreferences.Mode.always)
|
||||
}
|
||||
Toggle(AppStrings.CommandNotifications.system, isOn: binding(\.notify))
|
||||
Toggle(AppStrings.CommandNotifications.bell, isOn: binding(\.bell))
|
||||
LabeledContent {
|
||||
TextField(AppStrings.CommandNotifications.seconds, value: binding(\.after), format: .number)
|
||||
.labelsHidden()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 100)
|
||||
#if os(iOS)
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
} label: {
|
||||
Text(AppStrings.CommandNotifications.seconds)
|
||||
}
|
||||
if authorization == .denied {
|
||||
Text(AppStrings.CommandNotifications.denied).foregroundStyle(.secondary)
|
||||
Button(AppStrings.CommandNotifications.openSettings, action: openSettings)
|
||||
}
|
||||
} header: {
|
||||
Text(AppStrings.CommandNotifications.title)
|
||||
} footer: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(AppStrings.CommandNotifications.explanation)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#if os(iOS)
|
||||
Text(AppStrings.CommandNotifications.mobileExplanation)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.task { authorization = await service.authorization(request: false) }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { Task { authorization = await service.authorization(request: false) } }
|
||||
}
|
||||
}
|
||||
|
||||
private func binding<Value>(_ path: WritableKeyPath<CommandNotificationPreferences, Value>) -> Binding<Value> {
|
||||
Binding(
|
||||
get: { model.preferences.commandNotifications[keyPath: path] },
|
||||
set: { value in
|
||||
let previous = model.preferences.commandNotifications
|
||||
var next = previous
|
||||
next[keyPath: path] = value
|
||||
guard next.after.isFinite, next.after >= 0,
|
||||
model.set(next, for: \.commandNotifications)
|
||||
else { return }
|
||||
if next.notify, next.mode != .never, !previous.notify || previous.mode == .never {
|
||||
Task { authorization = await service.authorization(request: true) }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public struct WorkspaceCommandBadge: View {
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
Circle().fill(.red).frame(width: 7, height: 7)
|
||||
.overlay { Circle().stroke(.background, lineWidth: 1) }
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
public enum CommandNotificationContent {
|
||||
public static func body(_ completion: TerminalCommandCompletion) -> String {
|
||||
let duration = Duration.seconds(completion.duration).formatted(.units(allowed: [.hours, .minutes, .seconds]))
|
||||
let result: LocalizedStringResource
|
||||
switch completion.exitCode {
|
||||
case 0: result = AppStrings.CommandNotifications.succeeded(duration)
|
||||
case .some(let code): result = AppStrings.CommandNotifications.failed(code, duration)
|
||||
case nil: result = AppStrings.CommandNotifications.finished(duration)
|
||||
}
|
||||
return (completion.commandPreview ?? String(localized: AppStrings.CommandNotifications.completed))
|
||||
+ "\n" + String(localized: result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Combine
|
||||
import GhosttyTerminal
|
||||
import TermindApplication
|
||||
|
||||
/// The duration publisher emits once per completion, including equal values.
|
||||
/// Exit code is assigned first by Ghostty; capture both in the synchronous sink.
|
||||
@MainActor
|
||||
public final class TerminalCommandObservation {
|
||||
private var observation: AnyCancellable?
|
||||
|
||||
public init(state: TerminalViewState, monitor: TerminalCommandMonitor) {
|
||||
observation = state.$lastCommandDurationNanos.dropFirst().sink { [weak state, weak monitor] duration in
|
||||
guard let duration else { return }
|
||||
monitor?.receiveOSC133(exitCode: state?.lastCommandExitCode, durationNanos: duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ final class MobileAppModel {
|
||||
let forwardings: PortForwardingController
|
||||
let sftpEdits: SFTPEditService
|
||||
let transfers: SFTPTransferCoordinator
|
||||
let commandNotifications = SystemCommandNotifications()
|
||||
private var workspaceSessions: [MobileWorkspaceSessionModel] = []
|
||||
private var paneScenes: [UUID: WeakPaneScene] = [:]
|
||||
var workspaces: [MobileWorkspace] { workspaceSessions.flatMap(\.workspaces) }
|
||||
@@ -54,6 +55,7 @@ final class MobileAppModel {
|
||||
func registerPaneScene(_ scene: MobileSceneModel) {
|
||||
paneScenes = paneScenes.filter { $0.value.value != nil }
|
||||
paneScenes[scene.id] = WeakPaneScene(scene)
|
||||
commandNotifications.onOpenSession = { [weak self] id in self?.openCommandNotification(id) }
|
||||
}
|
||||
|
||||
func paneAccess(for source: MobileSceneModel) -> AgentPaneAccess {
|
||||
@@ -509,6 +511,7 @@ final class MobileAppModel {
|
||||
if let workspaceSessionID { sceneSessionIDs[id] = workspaceSessionID }
|
||||
let wasBackground = allScenesAreBackgrounded
|
||||
scenePhases[id] = phase
|
||||
refreshCommandFocus()
|
||||
let isBackground = allScenesAreBackgrounded
|
||||
if isBackground && !wasBackground {
|
||||
enterBackground()
|
||||
@@ -704,7 +707,45 @@ final class MobileAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func isCommandWorkspaceFocused(_ id: UUID) -> Bool {
|
||||
paneScenes.values.compactMap(\.value).contains {
|
||||
scenePhases[$0.id] == .active && $0.activeWorkspace?.id == id
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCommandFocus() {
|
||||
for scene in paneScenes.values.compactMap(\.value) where scenePhases[scene.id] == .active {
|
||||
scene.activeWorkspace?.commandMonitor.markRead()
|
||||
}
|
||||
}
|
||||
|
||||
private func openCommandNotification(_ id: UUID) {
|
||||
guard workspaces.contains(where: { $0.id == id }), let owner = workspaceOwner(id) else { return }
|
||||
if let scene = paneScenes.values.compactMap(\.value).first(where: { $0.workspaceSession.id == owner }) {
|
||||
scene.selectWorkspace(id)
|
||||
}
|
||||
MobileWorkspaceSceneRouting.activate(sessionID: owner, workspaceID: id, unavailable: {})
|
||||
}
|
||||
|
||||
func workspaceWillOpen(_ workspace: MobileWorkspace, close: @escaping (UUID) -> Void) {
|
||||
workspace.commandMonitor.preferences = { [weak self] in
|
||||
self?.terminalSettings.preferences.commandNotifications ?? .init()
|
||||
}
|
||||
workspace.commandMonitor.isWorkspaceFocused = { [weak self, weak workspace] in
|
||||
guard let self, let workspace else { return false }
|
||||
return self.isCommandWorkspaceFocused(workspace.id)
|
||||
}
|
||||
workspace.commandMonitor.alert = { [weak self, weak workspace] completion, settings in
|
||||
guard let self, let workspace else { return }
|
||||
if settings.bell { TerminalNotificationPlatform.ringBell() }
|
||||
if settings.notify {
|
||||
let title = workspace.descriptor.title
|
||||
let body = CommandNotificationContent.body(completion)
|
||||
let id = workspace.id
|
||||
Task { await self.commandNotifications.send(sessionID: id, title: title, body: body) }
|
||||
}
|
||||
}
|
||||
|
||||
observeActivityState(of: workspace)
|
||||
workspace.didRequestClose = close
|
||||
startConnectionHistory(for: workspace)
|
||||
|
||||
@@ -19,9 +19,10 @@ final class MobileSceneModel {
|
||||
var selectedDestination: MobileDestination? = .vault {
|
||||
didSet {
|
||||
if selectedDestination != .workspaces { pendingWorkspaceID = nil }
|
||||
app.refreshCommandFocus()
|
||||
}
|
||||
}
|
||||
var workspaceID: MobileWorkspace.ID?
|
||||
var workspaceID: MobileWorkspace.ID? { didSet { app.refreshCommandFocus() } }
|
||||
var isConnectionPickerPresented = false
|
||||
var isBatchStartupPresented = false
|
||||
var isWorkspaceUnavailable = false
|
||||
|
||||
@@ -24,7 +24,7 @@ struct MobileSettingsView: View {
|
||||
Label(AppStrings.Settings.app.swiftUIKey, systemImage: "app.badge")
|
||||
}
|
||||
NavigationLink {
|
||||
MobileTerminalSettingsView(model: app.terminalSettings)
|
||||
MobileTerminalSettingsView(model: app.terminalSettings, notifications: app.commandNotifications)
|
||||
} label: {
|
||||
Label(AppStrings.Settings.terminal.swiftUIKey, systemImage: "terminal")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import TermindPresentation
|
||||
|
||||
struct MobileTerminalSettingsView: View {
|
||||
let model: TerminalSettingsModel
|
||||
let notifications: any CommandNotificationSending
|
||||
@State private var editingDark: Bool?
|
||||
@State private var customText = ""
|
||||
@TerminalFontSettings private var font
|
||||
@@ -17,6 +18,9 @@ struct MobileTerminalSettingsView: View {
|
||||
Section(AppStrings.Settings.font.swiftUIKey) {
|
||||
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
|
||||
}
|
||||
TerminalCommandNotificationFields(
|
||||
model: model, service: notifications,
|
||||
openSettings: TerminalNotificationPlatform.openSettings)
|
||||
Section(AppStrings.Settings.integration.swiftUIKey) {
|
||||
Toggle(AppStrings.Settings.shellIntegration.swiftUIKey, isOn: binding(\.shellIntegration))
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ final class MobileWorkspace: Identifiable {
|
||||
private(set) var status = String(localized: AppStrings.Connections.disconnected)
|
||||
private(set) var connected = false {
|
||||
didSet {
|
||||
if !connected { commandMonitor.endConnection() }
|
||||
guard activitySession.connected != connected else { return }
|
||||
activitySession.connected = connected
|
||||
didChangeState?(activitySession)
|
||||
@@ -104,6 +105,8 @@ final class MobileWorkspace: Identifiable {
|
||||
perform: { [weak self] in _ = self?.terminal.performBindingAction($0) },
|
||||
focus: { [weak self] in self?.terminal.requestFocus() }
|
||||
)
|
||||
let commandMonitor = TerminalCommandMonitor()
|
||||
private var commandObservation: TerminalCommandObservation?
|
||||
private var activeHistoryID: UUID?
|
||||
/// Resolved once at the beginning of each connection attempt.
|
||||
private(set) var isShellIntegrationEnabled = true
|
||||
@@ -302,6 +305,7 @@ final class MobileWorkspace: Identifiable {
|
||||
relay?.owner?.receiveWorkingDirectory(path)
|
||||
}
|
||||
}
|
||||
commandObservation = TerminalCommandObservation(state: terminal, monitor: commandMonitor)
|
||||
surfaceOwner.bindFind(find)
|
||||
}
|
||||
|
||||
@@ -553,6 +557,7 @@ final class MobileWorkspace: Identifiable {
|
||||
resetCommandGeneratorShellState()
|
||||
return
|
||||
}
|
||||
commandMonitor.receiveShellEvent(event)
|
||||
switch event {
|
||||
case .available(let shell, let systemInfo, _):
|
||||
integratedShell = shell
|
||||
@@ -617,6 +622,7 @@ final class MobileWorkspace: Identifiable {
|
||||
let reconnecting = connection.isRecovering
|
||||
let token = UUID()
|
||||
generation = token
|
||||
commandMonitor.beginConnection()
|
||||
activeHistoryID = nil
|
||||
resetCommandGeneratorShellState()
|
||||
operationError = nil
|
||||
@@ -1189,6 +1195,7 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
commandMonitor.endConnection()
|
||||
cancelPendingUpload()
|
||||
stopSSHEventConsumer()
|
||||
resetCommandGeneratorShellState()
|
||||
|
||||
@@ -136,6 +136,14 @@ struct MobileWorkspaceListView: View {
|
||||
HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
hostIcon(item.host)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if item.workspace?.commandMonitor.hasUnreadCompletion == true {
|
||||
WorkspaceCommandBadge()
|
||||
}
|
||||
}
|
||||
.accessibilityValue(
|
||||
item.workspace?.commandMonitor.hasUnreadCompletion == true
|
||||
? String(localized: AppStrings.CommandNotifications.unread) : "")
|
||||
if let workspace = item.workspace,
|
||||
let indicator = MobileWorkspaceConnectionIndicator.resolve(
|
||||
connected: workspace.connected,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import AudioToolbox
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
enum TerminalNotificationPlatform {
|
||||
static func ringBell() {
|
||||
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil,
|
||||
let url = Bundle.main.url(forResource: "command-finished", withExtension: "wav")
|
||||
else { return }
|
||||
var sound: SystemSoundID = 0
|
||||
guard AudioServicesCreateSystemSoundID(url as CFURL, &sound) == kAudioServicesNoError else { return }
|
||||
let identifier = sound
|
||||
AudioServicesPlaySystemSoundWithCompletion(identifier) { AudioServicesDisposeSystemSoundID(identifier) }
|
||||
}
|
||||
|
||||
static func openSettings() {
|
||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"), .serialized)
|
||||
@MainActor
|
||||
struct MobileCommandNotificationRenderingTests {
|
||||
@Test(arguments: ["en", "zh-Hans"], [false, true])
|
||||
func notificationSettingsAndBadge(_ language: String, dark: Bool) async throws {
|
||||
let model = TerminalSettingsModel()
|
||||
var settings = CommandNotificationPreferences()
|
||||
settings.mode = .unfocused
|
||||
model.set(settings, for: \.commandNotifications)
|
||||
let view = Form {
|
||||
Section {
|
||||
Label {
|
||||
Text("Build Server")
|
||||
} icon: {
|
||||
Image(systemName: "terminal")
|
||||
.overlay(alignment: .topTrailing) { WorkspaceCommandBadge().offset(x: 3, y: -3) }
|
||||
}
|
||||
}
|
||||
TerminalCommandNotificationFields(model: model, service: SilentNotifications(), openSettings: {})
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, dark ? .dark : .light)
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previous = scene.keyWindow
|
||||
let window = UIWindow(windowScene: scene)
|
||||
let controller = UIHostingController(rootView: view)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previous?.makeKey()
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(350))
|
||||
window.layoutIfNeeded()
|
||||
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
||||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
let data = try #require(image.pngData())
|
||||
Attachment.record(data, named: "command-notifications-ios-\(language)-\(dark).png")
|
||||
}
|
||||
|
||||
private final class SilentNotifications: CommandNotificationSending {
|
||||
func authorization(request: Bool) async -> CommandNotificationAuthorization { .notDetermined }
|
||||
func send(sessionID: UUID, title: String, body: String) async {}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ struct MobileTerminalFontTests {
|
||||
defaults.set(family, forKey: SettingsKey.terminalFontFamily)
|
||||
defaults.set(18.0, forKey: SettingsKey.terminalFontSize)
|
||||
let view = NavigationStack {
|
||||
MobileTerminalSettingsView(model: TerminalSettingsModel())
|
||||
MobileTerminalSettingsView(model: TerminalSettingsModel(), notifications: SilentCommandNotifications())
|
||||
}
|
||||
.defaultAppStorage(defaults)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
@@ -97,3 +97,8 @@ struct MobileTerminalFontTests {
|
||||
Attachment.record(data, named: "\(name).png")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class SilentCommandNotifications: CommandNotificationSending {
|
||||
func authorization(request: Bool) async -> CommandNotificationAuthorization { .notDetermined }
|
||||
func send(sessionID: UUID, title: String, body: String) async {}
|
||||
}
|
||||
@@ -13,6 +13,39 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileWorkspaceSessionTests {
|
||||
@Test func commandCompletionDotsUseVisibleWorkspaceInItsOwningScene() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let app = try fixture.makeApp()
|
||||
let first = MobileSceneModel(app: app, sessionID: "command-a")
|
||||
let second = MobileSceneModel(app: app, sessionID: "command-b")
|
||||
first.open(hostID: fixture.host.id, kind: .ssh)
|
||||
second.open(hostID: fixture.host.id, kind: .ssh)
|
||||
first.activatePendingWorkspace()
|
||||
second.activatePendingWorkspace()
|
||||
let workspace = try #require(first.activeWorkspace)
|
||||
app.sceneChanged(second.id, phase: .active)
|
||||
app.sceneChanged(first.id, phase: .background)
|
||||
workspace.commandMonitor.beginConnection()
|
||||
workspace.commandMonitor.receiveOSC133(exitCode: 1, durationNanos: 1)
|
||||
#expect(workspace.commandMonitor.hasUnreadCompletion)
|
||||
#expect(!app.isCommandWorkspaceFocused(workspace.id))
|
||||
#expect(!second.activeWorkspace!.commandMonitor.hasUnreadCompletion)
|
||||
app.sceneChanged(first.id, phase: .active)
|
||||
#expect(app.isCommandWorkspaceFocused(workspace.id))
|
||||
#expect(!workspace.commandMonitor.hasUnreadCompletion)
|
||||
workspace.commandMonitor.receiveOSC133(exitCode: 0, durationNanos: 1)
|
||||
#expect(!workspace.commandMonitor.hasUnreadCompletion)
|
||||
first.workspaceID = nil
|
||||
workspace.commandMonitor.receiveOSC133(exitCode: nil, durationNanos: 0)
|
||||
#expect(workspace.commandMonitor.hasUnreadCompletion)
|
||||
first.selectWorkspace(workspace.id)
|
||||
#expect(!workspace.commandMonitor.hasUnreadCompletion)
|
||||
first.closeWorkspace(workspace.id)
|
||||
workspace.commandMonitor.receiveOSC133(exitCode: 0, durationNanos: 1)
|
||||
#expect(!workspace.commandMonitor.hasUnreadCompletion)
|
||||
}
|
||||
|
||||
@Test func paneAccessExcludesDormantAndDisconnectedWorkspacesAcrossScenes() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
|
||||
@@ -71,6 +71,8 @@ final class AppModel {
|
||||
let serverMetrics: ServerMetricsModel
|
||||
let windowSizeStore: MainWindowSizeStore
|
||||
let logging: any ApplicationLogManaging
|
||||
let commandNotifications = SystemCommandNotifications()
|
||||
|
||||
@ObservationIgnored lazy var mainWindows = MainWindowCoordinator(app: self, sizes: windowSizeStore)
|
||||
let connections: RemoteConnectionCoordinator
|
||||
let serialPorts: any SerialPortServing
|
||||
@@ -1097,7 +1099,73 @@ final class AppModel {
|
||||
if let first = opened.first { window.select(first.id) }
|
||||
}
|
||||
|
||||
func commandNotificationTarget(_ id: UUID) -> (WindowModel, Workspace, Pane)? {
|
||||
for window in windows.values.compactMap(\.value) where !window.isClosed {
|
||||
for workspace in window.workspaces {
|
||||
if let pane = workspace.panes.first(where: { $0.commandSessionID == id }) {
|
||||
return (window, workspace, pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCommandWorkspaceFocused(_ id: UUID, windowIsFocused: (WindowModel) -> Bool) -> Bool {
|
||||
guard let (window, workspace, _) = commandNotificationTarget(id) else { return false }
|
||||
return window.selectedWorkspace === workspace && windowIsFocused(window)
|
||||
}
|
||||
|
||||
private func configureCommandMonitor(_ pane: Pane) {
|
||||
guard let monitor = pane.commandMonitor, let id = pane.commandSessionID else { return }
|
||||
monitor.preferences = { [weak self] in self?.terminalSettings.preferences.commandNotifications ?? .init() }
|
||||
monitor.isWorkspaceFocused = { [weak self] in
|
||||
guard let self else { return false }
|
||||
return self.isCommandWorkspaceFocused(id, windowIsFocused: self.mainWindows.isFocused)
|
||||
}
|
||||
monitor.alert = { [weak self] completion, settings in
|
||||
guard let self, let (_, workspace, _) = self.commandNotificationTarget(id) else { return }
|
||||
if settings.bell { TerminalNotificationPlatform.ringBell() }
|
||||
if settings.notify {
|
||||
let title = workspace.title(in: self.hostLibrary, locale: .current)
|
||||
let body = CommandNotificationContent.body(completion)
|
||||
Task { await self.commandNotifications.send(sessionID: id, title: title, body: body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectCommandNotificationTarget(_ id: UUID) -> WindowModel? {
|
||||
guard let (window, workspace, pane) = commandNotificationTarget(id) else { return nil }
|
||||
window.isToolbarFindFocused = false
|
||||
window.isQuickOpenPresented = false
|
||||
window.select(workspace.id)
|
||||
workspace.focusedPaneID = pane.id
|
||||
return window
|
||||
}
|
||||
|
||||
private func openCommandNotification(_ id: UUID) {
|
||||
guard let window = selectCommandNotificationTarget(id) else {
|
||||
_ = mainWindows.openExisting()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
mainWindows.activate(window)
|
||||
if let pane = commandNotificationTarget(id)?.2 {
|
||||
pane.sshSession?.requestTerminalFocus()
|
||||
pane.telnetSession?.requestTerminalFocus()
|
||||
pane.serialSession?.requestTerminalFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureConnectionHistory(in window: WindowModel) {
|
||||
commandNotifications.onOpenSession = { [weak self] id in self?.openCommandNotification(id) }
|
||||
window.configureCommandNotifications = { [weak self] pane in self?.configureCommandMonitor(pane) }
|
||||
window.commandFocusChanged = { [weak self, weak window] in
|
||||
guard let self, let window, self.mainWindows.isFocused(window) else { return }
|
||||
window.selectedWorkspace?.markCommandsRead()
|
||||
}
|
||||
window.workspaces.flatMap(\.panes).forEach(configureCommandMonitor)
|
||||
|
||||
window.hostStore = hosts
|
||||
window.workspacePreferences = { [weak self] in
|
||||
self?.workspaceSettings.preferences ?? WorkspacePreferences()
|
||||
|
||||
@@ -620,6 +620,9 @@ private struct TerminalSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
TerminalCommandNotificationFields(
|
||||
model: app.terminalSettings, service: app.commandNotifications,
|
||||
openSettings: TerminalNotificationPlatform.openSettings)
|
||||
Section(AppStrings.Settings.integration.swiftUIKey) {
|
||||
Toggle(isOn: binding(\.shellIntegration)) {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
||||
|
||||
@@ -38,6 +38,7 @@ final class SSHSessionModel: Identifiable {
|
||||
var title: String
|
||||
var status: ConnectionStatus = .disconnected {
|
||||
didSet {
|
||||
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
|
||||
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
|
||||
if status.isLive != oldValue.isLive {
|
||||
inputRouter?.sshSessionDidChangeRemoteWindowTitle(self)
|
||||
@@ -62,6 +63,8 @@ final class SSHSessionModel: Identifiable {
|
||||
func displayPath(_ path: String) -> String {
|
||||
RemotePath.displayed(path, home: homeResolution)
|
||||
}
|
||||
let commandMonitor = TerminalCommandMonitor()
|
||||
private var commandObservation: TerminalCommandObservation?
|
||||
var hasUnreadOutput: Bool = false
|
||||
/// History for an unsaved Quick Connect destination. Saved Hosts use the
|
||||
/// app-wide CommandHistoryModel instead so every window sees the same list.
|
||||
@@ -258,6 +261,7 @@ final class SSHSessionModel: Identifiable {
|
||||
)
|
||||
terminalSurface = surface
|
||||
relay.owner = self
|
||||
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
|
||||
observeWorkingDirectory(from: surface.state, through: relay)
|
||||
observeRemoteWindowTitle(from: surface.state, through: relay)
|
||||
}
|
||||
@@ -359,6 +363,7 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
|
||||
private func startConnection(using store: any RemoteSessionHostStore) {
|
||||
commandMonitor.endConnection()
|
||||
resetCommandGeneratorShellState()
|
||||
remoteWindowTitle = nil
|
||||
homeResolution = .unavailable
|
||||
@@ -757,6 +762,7 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
commandMonitor.endConnection()
|
||||
startsOnMount = false
|
||||
resetCommandGeneratorShellState()
|
||||
remoteWindowTitle = nil
|
||||
@@ -812,6 +818,7 @@ final class SSHSessionModel: Identifiable {
|
||||
authenticationReference: authenticationReference
|
||||
)
|
||||
let terminalSurface = terminalSurface
|
||||
commandMonitor.beginConnection()
|
||||
let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel(
|
||||
output: { data in terminalSurface.receive(data) },
|
||||
exit: { [weak self] result in
|
||||
@@ -1121,6 +1128,7 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
|
||||
guard isShellIntegrationEnabled else { return }
|
||||
commandMonitor.receiveShellEvent(event)
|
||||
switch event {
|
||||
case .available(let shell, let systemInfo, let homeDirectory):
|
||||
if let homeDirectory, homeDirectory.hasPrefix("/") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import GhosttyTerminal
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
@MainActor
|
||||
@@ -26,7 +27,11 @@ nonisolated private final class WeakSerialSessionReference: @unchecked Sendable
|
||||
final class SerialSessionModel: Identifiable {
|
||||
let id = UUID()
|
||||
private(set) var configuration: SerialConnectionConfiguration
|
||||
var status: ConnectionStatus = .disconnected
|
||||
var status: ConnectionStatus = .disconnected {
|
||||
didSet { if !status.isLive, status != .connecting { commandMonitor.endConnection() } }
|
||||
}
|
||||
let commandMonitor = TerminalCommandMonitor()
|
||||
private var commandObservation: TerminalCommandObservation?
|
||||
var hasUnreadOutput = false
|
||||
@ObservationIgnored lazy var find = TerminalFindController(
|
||||
perform: { [weak self] in self?.performFind($0) },
|
||||
@@ -67,6 +72,7 @@ final class SerialSessionModel: Identifiable {
|
||||
resize: { _ in }
|
||||
)
|
||||
relay.owner = self
|
||||
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
|
||||
// Present the shared Journey before SwiftUI mounts the pane, preventing
|
||||
// a one-frame flash of the empty Ghostty surface before `onAppear`
|
||||
// starts the actual open attempt.
|
||||
@@ -89,6 +95,7 @@ final class SerialSessionModel: Identifiable {
|
||||
let previousConnection = connection
|
||||
connection = nil
|
||||
status = .connecting
|
||||
commandMonitor.beginConnection()
|
||||
connectionJourney.beginSerial(name: title, address: devicePath)
|
||||
let service = service
|
||||
let configuration = configuration
|
||||
|
||||
@@ -19,8 +19,13 @@ final class TelnetSessionModel: Identifiable {
|
||||
let hostID: Host.ID
|
||||
var title: String
|
||||
var status: ConnectionStatus = .disconnected {
|
||||
didSet { statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) }
|
||||
didSet {
|
||||
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
|
||||
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
|
||||
}
|
||||
}
|
||||
let commandMonitor = TerminalCommandMonitor()
|
||||
private var commandObservation: TerminalCommandObservation?
|
||||
var hasUnreadOutput = false
|
||||
@ObservationIgnored lazy var find = TerminalFindController(
|
||||
perform: { [weak self] in self?.performFind($0) },
|
||||
@@ -86,6 +91,7 @@ final class TelnetSessionModel: Identifiable {
|
||||
}
|
||||
)
|
||||
relay.owner = self
|
||||
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
|
||||
connectionJourney.beginTelnet(
|
||||
host: host, username: usernameHint, mode: .connection)
|
||||
}
|
||||
@@ -124,6 +130,7 @@ final class TelnetSessionModel: Identifiable {
|
||||
mode: connectionController.hasConnectedOnce ? .reconnection : .connection)
|
||||
status = connectionController.hasConnectedOnce ? .reconnecting : .connecting
|
||||
|
||||
commandMonitor.beginConnection()
|
||||
let terminalSurface = terminalSurface
|
||||
let token = UUID()
|
||||
let connection = TelnetConnection(
|
||||
|
||||
@@ -96,6 +96,9 @@ nonisolated enum PaneKind: Hashable, Sendable {
|
||||
@Observable
|
||||
final class Pane: Identifiable {
|
||||
let id = UUID()
|
||||
// Reopening can reuse Telnet/serial models. Old notifications must still
|
||||
// refer to the closed session, while moving a pane keeps this identity.
|
||||
private var commandNotificationID = UUID()
|
||||
var content: PaneContent
|
||||
private var connectionHistoryEntryID: ConnectionHistoryEntry.ID?
|
||||
private var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
|
||||
@@ -217,6 +220,8 @@ final class Pane: Identifiable {
|
||||
/// `disconnectConnection`. Keep the Pane itself so its name, duplicate
|
||||
/// index, focus identity, and place in the split tree all survive.
|
||||
func prepareForReopen() {
|
||||
commandNotificationID = UUID()
|
||||
commandMonitor?.markRead()
|
||||
switch content {
|
||||
case .sshSession(let sshSession):
|
||||
sshSession.inputRouter = nil
|
||||
@@ -253,6 +258,14 @@ final class Pane: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var commandSessionID: UUID? {
|
||||
commandMonitor == nil ? nil : commandNotificationID
|
||||
}
|
||||
|
||||
var commandMonitor: TerminalCommandMonitor? {
|
||||
sshSession?.commandMonitor ?? telnetSession?.commandMonitor ?? serialSession?.commandMonitor
|
||||
}
|
||||
|
||||
var hasUnreadOutput: Bool {
|
||||
switch content {
|
||||
case .sshSession(let sshSession): sshSession.hasUnreadOutput
|
||||
|
||||
@@ -40,6 +40,7 @@ final class WindowModel {
|
||||
assistantReturnDestination = selectedDestination
|
||||
}
|
||||
reconcileSidebarMode()
|
||||
commandFocusChanged?()
|
||||
}
|
||||
}
|
||||
@ObservationIgnored weak var remoteWindowTitleSink: (any WindowRemoteTitleSink)?
|
||||
@@ -304,6 +305,9 @@ final class WindowModel {
|
||||
set { workspaceDeck.reopenStack = newValue }
|
||||
}
|
||||
|
||||
var commandFocusChanged: (() -> Void)?
|
||||
var configureCommandNotifications: ((Pane) -> Void)?
|
||||
|
||||
var recordConnectionHistory: ((Host.ID, ConnectionHistoryEntry.Kind) -> ConnectionHistoryEntry.ID?)?
|
||||
var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
|
||||
|
||||
@@ -1020,6 +1024,7 @@ final class WindowModel {
|
||||
}
|
||||
|
||||
private func configureConnectionHistory(_ pane: Pane) {
|
||||
configureCommandNotifications?(pane)
|
||||
guard !pane.hasActiveConnectionHistory, let hostID = pane.hostID else { return }
|
||||
let kind: ConnectionHistoryEntry.Kind
|
||||
switch pane.kind {
|
||||
@@ -1562,6 +1567,7 @@ extension WindowModel: WorkspaceLifecycleRouting {
|
||||
}
|
||||
|
||||
func workspaceContextDidChange(_ workspace: Workspace) {
|
||||
commandFocusChanged?()
|
||||
guard selectedWorkspace === workspace else { return }
|
||||
refreshSessionContext()
|
||||
reconcileSidebarMode()
|
||||
|
||||
@@ -117,6 +117,12 @@ final class Workspace: Identifiable {
|
||||
|
||||
var status: ConnectionStatus { focusedPane.status }
|
||||
|
||||
var hasUnreadCommandCompletion: Bool { panes.contains { $0.commandMonitor?.hasUnreadCompletion == true } }
|
||||
|
||||
func markCommandsRead() {
|
||||
for pane in panes { pane.commandMonitor?.markRead() }
|
||||
}
|
||||
|
||||
var hasUnreadOutput: Bool {
|
||||
panes.contains { $0.hasUnreadOutput }
|
||||
}
|
||||
|
||||
@@ -472,6 +472,12 @@ private struct WorkspaceNavigatorRow: View {
|
||||
)
|
||||
)
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if workspace.hasUnreadCommandCompletion { WorkspaceCommandBadge().offset(x: 3, y: -3) }
|
||||
}
|
||||
.accessibilityValue(
|
||||
workspace.hasUnreadCommandCompletion
|
||||
? String(localized: AppStrings.CommandNotifications.unread) : "")
|
||||
|
||||
if isRenaming {
|
||||
SidebarInlineRenameField(
|
||||
|
||||
@@ -76,6 +76,17 @@ final class MainWindowCoordinator {
|
||||
init(app: AppModel, sizes: MainWindowSizeStore) {
|
||||
self.app = app
|
||||
self.sizes = sizes
|
||||
observers.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
for entry in self.entries where entry.window?.isKeyWindow == true {
|
||||
entry.model?.selectedWorkspace?.markCommandsRead()
|
||||
}
|
||||
}
|
||||
})
|
||||
for name in [
|
||||
NSWindow.willCloseNotification, NSWindow.didBecomeKeyNotification,
|
||||
NSWindow.didResizeNotification, NSWindow.didEndLiveResizeNotification,
|
||||
@@ -293,6 +304,7 @@ final class MainWindowCoordinator {
|
||||
// A restored scene has no pending request. Its system-restored frame
|
||||
// must not be replaced with another window's shared fallback.
|
||||
entry.isReady = true
|
||||
if isFocused(model) { model.selectedWorkspace?.markCommandsRead() }
|
||||
recordSize(entry)
|
||||
if let width = model.sidebar.measuredWidth { recordSidebarWidth(width, for: model) }
|
||||
synchronizePaywall()
|
||||
@@ -382,6 +394,9 @@ final class MainWindowCoordinator {
|
||||
}
|
||||
|
||||
private func becameKey(_ native: NSWindow) {
|
||||
if NSApp.isActive {
|
||||
entries.first(where: { $0.window === native })?.model?.selectedWorkspace?.markCommandsRead()
|
||||
}
|
||||
guard let index = entries.firstIndex(where: { $0.window === native }) else { return }
|
||||
entries.append(entries.remove(at: index))
|
||||
if let entry = entries.last {
|
||||
@@ -392,6 +407,18 @@ final class MainWindowCoordinator {
|
||||
synchronizePaywall()
|
||||
}
|
||||
|
||||
func isFocused(_ model: WindowModel) -> Bool {
|
||||
NSApp.isActive && entries.contains { $0.model === model && $0.window?.isKeyWindow == true }
|
||||
}
|
||||
|
||||
func activate(_ model: WindowModel) {
|
||||
guard let native = entries.first(where: { $0.model === model })?.window else { return }
|
||||
native.deminiaturize(nil)
|
||||
native.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
model.selectedWorkspace?.markCommandsRead()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func openExisting(showsMetrics: Bool = false) -> Bool {
|
||||
guard let entry = entries.reversed().first(where: { $0.window != nil && $0.model?.isClosed == false }),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum TerminalNotificationPlatform {
|
||||
static func ringBell() {
|
||||
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
static func openSettings() {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite(.requiresVisibleWindow, .serialized) @MainActor
|
||||
struct CommandNotificationRenderingTests {
|
||||
@Test(arguments: ["en", "zh-Hans"], [false, true])
|
||||
func notificationsAndWorkspaceDot(_ language: String, dark: Bool) async throws {
|
||||
let host = Host(name: "Build Server", host: "build.example")
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
||||
let windowModel = WindowModel()
|
||||
app.registerWindow(windowModel)
|
||||
let pane = Pane.sshSession(host: host)
|
||||
windowModel.open(Workspace(pane: pane), select: false)
|
||||
pane.commandMonitor?.beginConnection()
|
||||
pane.commandMonitor?.receiveOSC133(exitCode: 0, durationNanos: 1)
|
||||
var settings = CommandNotificationPreferences()
|
||||
settings.mode = .unfocused
|
||||
app.terminalSettings.set(settings, for: \.commandNotifications)
|
||||
let view = HStack(spacing: 0) {
|
||||
WorkspaceNavigatorView().frame(width: 230)
|
||||
Form {
|
||||
TerminalCommandNotificationFields(
|
||||
model: app.terminalSettings,
|
||||
service: app.commandNotifications, openSettings: {})
|
||||
}.formStyle(.grouped).frame(width: 520)
|
||||
}
|
||||
.environment(app).environment(windowModel)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, dark ? .dark : .light)
|
||||
.frame(width: 750, height: 560)
|
||||
let controller = NSHostingController(rootView: view)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 560),
|
||||
styleMask: [.borderless], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentViewController = controller
|
||||
window.orderFront(nil)
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
app.shutdownForTermination()
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(350))
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
|
||||
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
|
||||
let data = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
Attachment.record(data, named: "command-notifications-mac-\(language)-\(dark).png")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import GhosttyTerminal
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite @MainActor
|
||||
struct CommandNotificationTests {
|
||||
@Test func appOwnsCommandNotificationsWithoutChangingThemeOrFontPrecedence() {
|
||||
let state = TerminalViewState(configSource: .none)
|
||||
let preferences = TerminalPreferences(
|
||||
darkCustomEnabled: true,
|
||||
darkCustomConfiguration: "notify-on-command-finish = always\nbackground = 010203")
|
||||
state.applyPreferences(preferences)
|
||||
#expect(state.controller.terminalConfiguration.rendered.contains("notify-on-command-finish = never"))
|
||||
#expect(!state.theme.dark.rendered.contains("notify-on-command-finish = always"))
|
||||
#expect(state.theme.dark.rendered.hasSuffix("background = 010203"))
|
||||
}
|
||||
|
||||
@Test func ghosttyPublishesEveryCompletionAndPairsItsExitCode() async {
|
||||
let state = TerminalViewState(configSource: .none)
|
||||
let monitor = TerminalCommandMonitor()
|
||||
var preferences = CommandNotificationPreferences()
|
||||
preferences.mode = .always
|
||||
preferences.after = 0
|
||||
monitor.preferences = { preferences }
|
||||
var received: [TerminalCommandCompletion] = []
|
||||
monitor.alert = { completion, _ in received.append(completion) }
|
||||
monitor.beginConnection()
|
||||
let observation = TerminalCommandObservation(state: state, monitor: monitor)
|
||||
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1_000_000_000)
|
||||
state.terminalDidFinishCommand(exitCode: 7, durationNanos: 1_000_000_000)
|
||||
state.terminalDidFinishCommand(exitCode: nil, durationNanos: 1_000_000_000)
|
||||
for _ in 0..<100 where received.count < 3 { try? await Task.sleep(for: .milliseconds(10)) }
|
||||
#expect(received.map(\.exitCode) == [0, 7, nil])
|
||||
#expect(received.map(\.duration) == [1, 1, 1])
|
||||
withExtendedLifetime(observation) {}
|
||||
}
|
||||
|
||||
@Test func workspaceFocusIncludesEveryPaneAndTargetsFollowMoves() throws {
|
||||
let host = Host(name: "Build", host: "test.invalid")
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
||||
let window = WindowModel()
|
||||
let other = WindowModel()
|
||||
app.registerWindow(window)
|
||||
app.registerWindow(other)
|
||||
let first = Pane.sshSession(host: host)
|
||||
let second = Pane.sshSession(host: host)
|
||||
let workspace = Workspace(pane: first)
|
||||
workspace.splitFocusedPane(axis: .horizontal, with: second)
|
||||
window.open(workspace)
|
||||
let firstID = try #require(first.commandSessionID)
|
||||
let secondID = try #require(second.commandSessionID)
|
||||
#expect(workspace.focusedPane === second)
|
||||
#expect(app.isCommandWorkspaceFocused(firstID, windowIsFocused: { $0 === window }))
|
||||
#expect(app.isCommandWorkspaceFocused(secondID, windowIsFocused: { $0 === window }))
|
||||
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in false }))
|
||||
window.selectVault()
|
||||
#expect(app.selectCommandNotificationTarget(firstID) === window)
|
||||
#expect(window.selectedWorkspace === workspace)
|
||||
#expect(workspace.focusedPane === first)
|
||||
window.selectVault()
|
||||
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in true }))
|
||||
let monitor = try #require(first.commandMonitor)
|
||||
monitor.beginConnection()
|
||||
monitor.receiveOSC133(exitCode: 0, durationNanos: 0)
|
||||
#expect(workspace.hasUnreadCommandCompletion)
|
||||
let moved = try #require(window.takePaneForMove(first.id))
|
||||
other.open(moved)
|
||||
#expect(!workspace.hasUnreadCommandCompletion)
|
||||
#expect(moved.hasUnreadCommandCompletion)
|
||||
#expect(app.commandNotificationTarget(firstID)?.0 === other)
|
||||
other.selectVault()
|
||||
#expect(app.selectCommandNotificationTarget(firstID) === other)
|
||||
#expect(other.selectedWorkspace === moved)
|
||||
#expect(moved.focusedPane === first)
|
||||
moved.markCommandsRead()
|
||||
#expect(!moved.hasUnreadCommandCompletion)
|
||||
other.close(moved.id)
|
||||
#expect(app.commandNotificationTarget(firstID) == nil)
|
||||
#expect(app.selectCommandNotificationTarget(firstID) == nil)
|
||||
other.reopenLastClosed()
|
||||
#expect(app.commandNotificationTarget(firstID) == nil)
|
||||
#expect(first.commandSessionID != firstID)
|
||||
}
|
||||
|
||||
@Test func ghosttyValuesAlreadyPublishedAreNotReplayedWhenObserving() async {
|
||||
let state = TerminalViewState(configSource: .none)
|
||||
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
|
||||
try? await Task.sleep(for: .milliseconds(30))
|
||||
let monitor = TerminalCommandMonitor()
|
||||
monitor.beginConnection()
|
||||
let observation = TerminalCommandObservation(state: state, monitor: monitor)
|
||||
#expect(!monitor.hasUnreadCompletion)
|
||||
monitor.endConnection()
|
||||
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
|
||||
try? await Task.sleep(for: .milliseconds(30))
|
||||
#expect(!monitor.hasUnreadCompletion)
|
||||
withExtendedLifetime(observation) {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user