feat: remove persistence connection and SFTP history

This commit is contained in:
Kaede Akino committed 2026-10-03 04:18:14 +08:00
1 parent 5d5a3585ac
commit aa5a92c352
54 files changed
+1790 -1811

No files matched your search

@@ -0,0 +1,108 @@
#if os(macOS)
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
@Suite
struct RemoteShellHistoryLoaderTests {
@Test func execReadsQuotedPathsAsDataAndWorksWithoutIntegration() async throws {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: folder) }
let file = folder.appendingPathComponent("history ' quoted; $(false)")
try Data("pwd\necho $(false)\n".utf8).write(to: file)
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command, environment: ["SHELL": "/bin/bash", "HISTFILE": file.path])
})
let standard = try await loader.load(source: nil)
let custom = try await loader.load(source: .init(shell: .bash, path: file.path))
#expect(standard.map(\.command) == ["pwd", "echo $(false)"])
#expect(custom == standard)
#expect(standard.allSatisfy { $0.executedAt == nil })
#expect(try String(contentsOf: file, encoding: .utf8) == "pwd\necho $(false)\n")
}
@Test func readsOnlyBoundedTailAndKeepsCompleteRecentRecords() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
var content = Data(String(repeating: "x", count: RemoteShellHistoryLoader.maximumFileBytes + 100).utf8)
content.append(Data("\n#123\npwd\n#124\nwhoami\n".utf8))
try content.write(to: file)
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command)
})
let records = try await loader.load(source: .init(shell: .bash, path: file.path))
#expect(records.map(\.command) == ["pwd", "whoami"])
}
@Test func missingAndExplicitlyDisabledFilesAreSuccessfulEmptyReads() async throws {
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command)
})
#expect(try await loader.load(source: .init(shell: .zsh, path: "/nonexistent-\(UUID())")).isEmpty)
#expect(try await loader.load(source: .init(shell: .bash, path: "")).isEmpty)
}
@Test func zshMetaficationAndRemoteCharsetAreDecodedBeforeParsing() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
let bytes = try RemoteCharsetStreamConverter.encode(": 123:0;echo 中文\n", charset: .gb18030)
var metafied = Data()
for byte in bytes {
if byte >= 0x80 {
metafied.append(0x83)
metafied.append(byte ^ 0x20)
} else {
metafied.append(byte)
}
}
try metafied.write(to: file)
let loader = RemoteShellHistoryLoader(
charset: .gb18030, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command)
})
#expect(try await loader.load(source: .init(shell: .zsh, path: file.path)).map(\.command) == ["echo 中文"])
}
@Test func execFailureDoesNotReturnAPartialSnapshot() async {
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { _, _ in
SSHExecResult(
stdout: "partial", stderr: "denied", exitCode: 1, stdoutTruncated: false, stderrTruncated: false
)
})
await #expect(throws: (any Error).self) { try await loader.load(source: nil) }
}
private nonisolated static func execute(_ command: String, environment: [String: String] = [:]) throws
-> SSHExecResult
{
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, replacement in
replacement
}
let output = Pipe()
process.standardOutput = output
process.standardError = FileHandle.nullDevice
try process.run()
let bytes = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return SSHExecResult(
stdout: String(decoding: bytes, as: UTF8.self), stderr: "", exitCode: process.terminationStatus,
stdoutTruncated: false, stderrTruncated: false)
}
}
#endif
+33 -98
View File
@@ -1,108 +1,43 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite
@MainActor
@Suite @MainActor
struct SFTPHistoryTests {
@Test func memoryStoreNormalizesDeduplicatesSearchesAndScopesByHost() throws {
let firstHost = UUID()
let secondHost = UUID()
@Test func visitsAreNormalizedDeduplicatedAndScopedToSession() {
let first = SFTPHistoryModel()
let second = SFTPHistoryModel()
first.record(kind: .directory, path: "/srv/app/")
first.record(kind: .file, path: "/srv/app/config.json")
first.record(kind: .directory, path: "/srv/app")
second.record(kind: .directory, path: "/var/log")
#expect(first.entries().map(\.path) == ["/srv/app", "/srv/app/config.json"])
#expect(first.entries(matching: "CONFIG").map(\.kind) == [.file])
#expect(second.entries().map(\.path) == ["/var/log"])
first.close()
#expect(first.entries().isEmpty)
#expect(second.entries().count == 1)
}
@Test func closedHistoryRejectsLateTransferCallbacks() {
let history = SFTPHistoryModel()
history.record(kind: .file, path: "/before")
history.close()
history.record(kind: .file, path: "/late")
#expect(history.entries().isEmpty)
let reopened = SFTPHistoryModel()
#expect(reopened.entries().isEmpty)
#expect(reopened.sessionID != history.sessionID)
}
@Test func trimRetainsNewestPaths() throws {
let model = SFTPHistoryModel()
model.record(
kind: .directory, path: "/srv/../srv/app", for: firstHost,
at: Date(timeIntervalSince1970: 1)
)
model.record(
kind: .file, path: "/srv/app/config.json", for: firstHost,
at: Date(timeIntervalSince1970: 2)
)
model.record(
kind: .directory, path: "/srv/app", for: firstHost,
at: Date(timeIntervalSince1970: 3)
)
model.record(
kind: .directory, path: "/var/log", for: secondHost,
at: Date(timeIntervalSince1970: 4)
)
#expect(model.entries(for: firstHost).map(\.kind) == [.directory, .file])
#expect(model.entries(for: firstHost).map(\.path) == ["/srv/app", "/srv/app/config.json"])
#expect(model.entries(for: firstHost, matching: "CONFIG").map(\.kind) == [.file])
#expect(model.entries(for: secondHost).map(\.path) == ["/var/log"])
}
@Test func memoryStoreTrimsDeletesAndClearsPerHost() throws {
let hostID = UUID()
let otherHostID = UUID()
let model = SFTPHistoryModel()
for index in 0...SFTPHistoryModel.perHostLimit {
model.record(
kind: .directory, path: "/directory-\(index)", for: hostID,
at: Date(timeIntervalSince1970: TimeInterval(index))
)
for index in 0...SFTPHistoryModel.perSessionLimit {
model.record(kind: .directory, path: "/directory-\(index)")
}
model.record(kind: .file, path: "/keep.txt", for: otherHostID)
let entries = model.entries(for: hostID)
#expect(entries.count == SFTPHistoryModel.perHostLimit)
#expect(entries.first?.path == "/directory-\(HistoryLimits.retainedEntries)")
#expect(entries.last?.path == "/directory-1")
let removed = try #require(entries.first)
model.delete(removed.id)
#expect(!model.entries(for: hostID).contains { $0.id == removed.id })
model.clear(for: hostID)
#expect(model.entries(for: hostID).isEmpty)
#expect(model.entries(for: otherHostID).map(\.path) == ["/keep.txt"])
}
@Test func loadedSFTPHistoryImmediatelyTrimsEveryHostToTheSharedLimit() {
let hosts = [UUID(), UUID()]
let entries = hosts.flatMap { hostID in
(0...HistoryLimits.retainedEntries).map { index in
SFTPHistoryEntry(
hostID: hostID, kind: .directory, path: "/directory-\(index)",
visitedAt: Date(timeIntervalSince1970: TimeInterval(index))
)
}
}
let model = SFTPHistoryModel(entries: entries)
for hostID in hosts {
let retained = model.entries(for: hostID)
#expect(retained.count == HistoryLimits.retainedEntries)
#expect(retained.first?.path == "/directory-\(HistoryLimits.retainedEntries)")
#expect(retained.last?.path == "/directory-1")
}
}
@Test func swiftDataRepositoryPersistsAndClearsPerHost() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let hostID = UUID()
let otherHostID = UUID()
let model = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container))
)
model.record(kind: .directory, path: "/var/log", for: hostID)
model.record(kind: .file, path: "/tmp/a.txt", for: otherHostID)
#expect(model.storageError == nil)
let reloaded = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container))
)
#expect(reloaded.entries(for: hostID).map(\.path) == ["/var/log"])
#expect(reloaded.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"])
reloaded.clear(for: hostID)
#expect(reloaded.entries(for: hostID).isEmpty)
#expect(reloaded.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"])
let values = model.entries()
#expect(values.count == SFTPHistoryModel.perSessionLimit)
#expect(values.last?.path == "/directory-1")
}
}
+214
View File
@@ -0,0 +1,214 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import TermindPresentation
import Testing
@testable import TermindRemoteInfrastructure
@Suite @MainActor
struct SessionHistoryTests {
@Test func snapshotDoesNotReplaceLiveCommandsAndLoadsOnlyOnce() async throws {
let gate = HistoryReadGate()
let model = CommandHistoryModel()
model.load(using: gate, source: nil)
await gate.waitForRead()
let live = try #require(model.record("pwd", at: Date(timeIntervalSince1970: 12.5)))
model.recordCompletion(for: live.id, exitStatus: 0, duration: 0.25)
await gate.finish([
.init(command: "pwd", executedAt: Date(timeIntervalSince1970: 10)),
.init(command: "old without time"),
.init(command: "pwd", executedAt: Date(timeIntervalSince1970: 12)),
])
#expect(await settles { model.hasLoaded })
let entries = model.entries()
#expect(entries.map(\.command) == ["pwd", "old without time", "pwd"])
#expect(entries.first?.id == live.id)
#expect(entries.first?.exitStatus == 0)
#expect(entries.first?.duration == 0.25)
model.cancelLoading()
model.load(using: gate, source: nil)
#expect(await gate.readCount == 1)
}
@Test func cancelledOrClosedReadsCannotPublishAndReconnectCanRetry() async {
let stale = HistoryReadGate()
let current = HistoryReadGate()
let model = CommandHistoryModel()
model.record("live")
model.load(using: stale, source: nil)
await stale.waitForRead()
model.cancelLoading()
model.load(using: current, source: nil)
await current.waitForRead()
await stale.finish([.init(command: "stale")])
await current.finish([.init(command: "current")])
#expect(await settles { model.hasLoaded })
#expect(model.entries().map(\.command) == ["live", "current"])
let closing = HistoryReadGate()
let closed = CommandHistoryModel()
closed.load(using: closing, source: nil)
await closing.waitForRead()
closed.close()
await closing.finish([.init(command: "too late")])
for _ in 0..<20 { await Task.yield() }
#expect(closed.entries().isEmpty)
#expect(closed.record("callback") == nil)
#expect(!closed.hasLoaded)
}
@Test func failureKeepsLiveRecordsAndAnEmptySuccessfulReadStopsRetries() async {
let model = CommandHistoryModel()
model.record("retained")
model.load(using: FailedHistoryRead(), source: nil)
#expect(await settles { model.loadError != nil })
#expect(model.entries().map(\.command) == ["retained"])
let empty = HistoryReadGate()
model.load(using: empty, source: nil)
await empty.waitForRead()
await empty.finish([])
#expect(await settles { model.hasLoaded })
#expect(model.loadError == nil)
}
@Test func unknownTimeHasNoSubtitleButKnownDurationStillDisplays() {
var entry = CommandHistoryEntry(sessionID: UUID(), command: "pwd")
#expect(CommandHistoryPresentation.subtitle(for: entry, locale: Locale(identifier: "en")).isEmpty)
entry.duration = 2
#expect(!CommandHistoryPresentation.subtitle(for: entry, locale: Locale(identifier: "en")).isEmpty)
}
private func settles(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<1000 {
if predicate() { return true }
await Task.yield()
}
return predicate()
}
}
private actor HistoryReadGate: ShellHistoryLoading {
private var continuation: CheckedContinuation<[ShellHistoryRecord], Never>?
private(set) var readCount = 0
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
readCount += 1
return await withCheckedContinuation { continuation = $0 }
}
func waitForRead() async {
while continuation == nil { await Task.yield() }
}
func finish(_ records: [ShellHistoryRecord]) {
continuation?.resume(returning: records)
continuation = nil
}
}
private struct FailedHistoryRead: ShellHistoryLoading {
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
throw CocoaError(.fileReadNoPermission)
}
}
@Suite
struct ShellHistoryParsingTests {
@Test func bashPreservesRepeatedCommandsAndTimestampedMultilineRecords() {
let records = ShellHistoryParser.parse("#100\nprintf 'one\ntwo'\n#101\npwd\n#102\npwd\n", shell: .bash)
#expect(records.map(\.command) == ["printf 'one\ntwo'", "pwd", "pwd"])
#expect(records.map { $0.executedAt?.timeIntervalSince1970 } == [100, 101, 102])
let plain = ShellHistoryParser.parse("pwd\npwd\npartial", shell: .bash)
#expect(plain.map(\.command) == ["pwd", "pwd"])
#expect(plain.allSatisfy { $0.executedAt == nil })
}
@Test func zshAndFishDecodeTheirOwnMultilineFormats() {
let zsh = ShellHistoryParser.parse(": 100:2;printf 'one\\\ntwo'\n: 103:0;pwd\n", shell: .zsh)
#expect(zsh.map(\.command) == ["printf 'one\ntwo'", "pwd"])
#expect(zsh.first?.duration == 2)
let fish = ShellHistoryParser.parse(
"- cmd: printf one\\ntwo\\\\three\n when: 100\n paths:\n - /tmp\n- cmd: pwd\n", shell: .fish)
#expect(fish.map(\.command) == ["printf one\ntwo\\three", "pwd"])
#expect(fish.first?.executedAt?.timeIntervalSince1970 == 100)
#expect(fish.last?.executedAt == nil)
}
@Test func truncatedRecordsDoNotTurnIntoCommands() {
#expect(
ShellHistoryParser.parse("broken\nremaining\n#123\npwd\n", shell: .bash, truncatedStart: true)
.map(\.command) == ["pwd"])
#expect(
ShellHistoryParser.parse("broken\nremaining\n: 123:0;pwd\n", shell: .zsh, truncatedStart: true)
.map(\.command) == ["pwd"])
#expect(
ShellHistoryParser.parse("broken\n when: 123\n- cmd: pwd\n", shell: .fish, truncatedStart: true)
.map(\.command) == ["pwd"])
}
@Test(arguments: ["bash", "zsh", "fish"])
func realInjectedScriptsAreFilteredWithoutHidingUserText(shell: String) throws {
var bootstrap = RemoteShellIntegrationBootstrap(token: "OLD123")
#expect(ShellHistoryFilter.isIntegrationCommand(String(decoding: bootstrap.startCommand, as: UTF8.self)))
let update = bootstrap.receive(Data("__TRMD_OLD123_SHELL__/bin/\(shell)__\n".utf8))
let install = String(decoding: try #require(update.command), as: UTF8.self)
#expect(ShellHistoryFilter.isIntegrationCommand(install))
#expect(ShellHistoryFilter.isIntegrationCommand("__termind_report_prompt"))
#expect(!ShellHistoryFilter.isIntegrationCommand("echo __termind_report_prompt"))
#expect(!ShellHistoryFilter.isIntegrationCommand("grep '__TRMD_OLD123_PROBE__' log"))
#expect(!ShellHistoryFilter.isIntegrationCommand("npm run integration"))
}
@Test func bootstrapCarriesExplicitCustomOrDisabledHistoryPaths() throws {
for path in ["%2Fhome%2Fuser%2Fcustom%20history", ""] {
var bootstrap = RemoteShellIntegrationBootstrap(token: "SOURCE")
_ = bootstrap.receive(Data("__TRMD_SOURCE_SHELL__/bin/bash__\n".utf8))
let result = bootstrap.receive(Data("__TRMD_SOURCE_HISTORY__\(path)\n__TRMD_SOURCE_DONE__\n".utf8))
#expect(result.historySource?.shell == .bash)
#expect(result.historySource?.path == (path.isEmpty ? "" : "/home/user/custom history"))
}
}
}
@Suite @MainActor
struct RetiredSessionHistoryMigrationTests {
@Test(arguments: [9, 48])
func oldStoresDropSessionHistoryAndRetainOtherDataAfterReopening(version: Int) throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("History.store")
let group = HostGroup(name: "Keep this group")
try writeLegacyStore(version: version, url: url, group: group)
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let names = container.schema.entities.map(\.name)
#expect(!names.contains("PersistentCommandHistoryEntry"))
#expect(!names.contains("PersistentSFTPHistoryEntry"))
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.value) == [group])
}
}
private func writeLegacyStore(version: Int, url: URL, group: HostGroup) throws {
let schema =
version == 9
? Schema(versionedSchema: TermindSchemaV9.self) : Schema(versionedSchema: TermindSchemaV48.self)
let container = try ModelContainer(
for: schema,
configurations: [
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
container.mainContext.insert(PersistentHostGroup(group))
if version == 9 {
container.mainContext.insert(
TermindSchemaV9Models.PersistentCommandHistoryEntry(
hostID: UUID(), command: "old", executedAt: Date()))
} else {
container.mainContext.insert(
PersistentCommandHistoryEntry(hostID: UUID(), command: "old", executedAt: Date()))
}
container.mainContext.insert(
PersistentSFTPHistoryEntry(hostID: UUID(), kind: "directory", path: "/old", visitedAt: Date()))
try container.mainContext.save()
}
}
+5 -26
View File
@@ -158,38 +158,17 @@ struct SettingsPortTests {
@Test func historyRetainsTheNewestSharedLimitAndUnknownCompletion() {
let model = CommandHistoryModel()
let host = UUID()
#expect(model.record(" \n\t ", for: host) == nil)
for index in 0...CommandHistoryModel.perHostLimit {
model.record("command \(index)", for: host, at: Date(timeIntervalSince1970: Double(index)))
#expect(model.record(" \n\t ") == nil)
for index in 0...CommandHistoryModel.perSessionLimit {
model.record("command \(index)")
}
let values = model.entries(for: host)
#expect(values.count == CommandHistoryModel.perHostLimit)
let values = model.entries()
#expect(values.count == CommandHistoryModel.perSessionLimit)
#expect(values.first?.command == "command \(HistoryLimits.retainedEntries)")
#expect(values.last?.command == "command 1")
#expect(values.allSatisfy { $0.exitStatus == nil && $0.duration == nil })
}
@Test func loadedCommandHistoryImmediatelyTrimsEveryHostToTheSharedLimit() {
let hosts = [UUID(), UUID()]
let entries = hosts.flatMap { hostID in
(0...HistoryLimits.retainedEntries).map { index in
CommandHistoryEntry(
hostID: hostID, command: "command \(index)",
executedAt: Date(timeIntervalSince1970: TimeInterval(index))
)
}
}
let model = CommandHistoryModel(entries: entries)
for hostID in hosts {
let retained = model.entries(for: hostID)
#expect(retained.count == HistoryLimits.retainedEntries)
#expect(retained.first?.command == "command \(HistoryLimits.retainedEntries)")
#expect(retained.last?.command == "command 1")
}
}
}
@MainActor private final class FailingTerminalPreferences: TerminalPreferencesPersisting {
+46 -102
View File
@@ -5406,23 +5406,6 @@
}
}
},
"Clear File History for this Host?" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除此主机的文件历史?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除此主機的檔案歷史?"
}
}
}
},
"Clear Filter" : {
"extractionState" : "manual",
"localizations" : {
@@ -5491,23 +5474,6 @@
}
}
},
"Clear History?" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清空历史记录?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除歷史?"
}
}
}
},
"Clear Key" : {
"extractionState" : "manual",
"localizations" : {
@@ -5576,23 +5542,6 @@
}
}
},
"Clear command history for this Host?" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除此主机的命令历史?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除此主機的指令歷史?"
}
}
}
},
"Clear connection history?" : {
"comment" : "A confirmation dialog asking the user to clear their connection history.",
"extractionState" : "manual",
@@ -6312,23 +6261,6 @@
}
}
},
"Commands confirmed by the remote shell appear here." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "远程shell确认的命令会显示在这里。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "遠端shell確認的指令會顯示在這裡。"
}
}
}
},
"Communication" : {
"extractionState" : "manual",
"localizations" : {
@@ -10856,6 +10788,29 @@
}
}
},
"Enable Shell Integration to add commands to this session’s history in real time." : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enable Shell Integration to add commands to this session’s history in real time."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "启用Shell集成以实时添加当前会话的命令历史。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "啟用Shell整合以即時加入目前工作階段的指令歷史。"
}
}
}
},
"Enable Web Search" : {
"extractionState" : "manual",
"localizations" : {
@@ -25630,6 +25585,29 @@
}
}
},
"Shell history and commands from this session appear here." : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shell history and commands from this session appear here."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shell历史和当前会话的命令会显示在这里。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shell歷史和目前工作階段的指令會顯示在這裡。"
}
}
}
},
"Shift" : {
"extractionState" : "manual",
"localizations" : {
@@ -31135,23 +31113,6 @@
}
}
},
"This permanently removes all saved commands for the current Host." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "这将永久删除当前主机所有保存的命令。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "這將永久刪除目前主機所有儲存的指令。"
}
}
}
},
"This permanently removes connection, command, and File History without deleting the Hosts." : {
"extractionState" : "manual",
"localizations" : {
@@ -31254,23 +31215,6 @@
}
}
},
"This session is not recording history. Enable Shell Integration and use bash, zsh, or fish." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此会话未记录历史。请启用Shell集成,并使用bash、zsh或fish。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此工作階段未記錄歷史。請啟用Shell整合,並使用bash、zsh或fish。"
}
}
}
},
"This settings record requires sync format %lld." : {
"extractionState" : "manual",
"localizations" : {
@@ -1,112 +1,128 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
/// Incremental persistence boundary for terminal history.
///
/// Preview and focused-test instances use the in-memory projection. The app
/// supplies its SwiftData context so inserts and deletes touch only history
/// rows, independently of HostLibraryModel's compatibility snapshot persistence.
/// One terminal's memory-only file snapshot and subsequent shell events.
@MainActor
@Observable
public final class CommandHistoryModel {
public static let perHostLimit = HistoryLimits.retainedEntries
private let repository: any CommandHistoryRepository
public static let perSessionLimit = HistoryLimits.retainedEntries
public let sessionID: UUID
public private(set) var revision: UInt64 = 0
public private(set) var storageError: String?
public private(set) var loadError: String?
public private(set) var isLoading = false
public private(set) var hasLoaded = false
private var values: [CommandHistoryEntry]
private var isClosed = false
private var loadTask: Task<Void, Never>?
private var loadID: UUID?
public init(
entries: [CommandHistoryEntry] = [],
repository: (any CommandHistoryRepository)? = nil
) {
self.repository = repository ?? InMemoryCommandHistoryRepository(entries: entries)
do {
try self.repository.trim(to: Self.perHostLimit)
} catch {
storageError = error.localizedDescription
}
}
public func entries(for hostID: Host.ID, matching query: String = "") -> [CommandHistoryEntry] {
let values: [CommandHistoryEntry]
do {
values = try repository.entries(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return []
}
let normalized = values.map { entry in
var entry = entry
public init(sessionID: UUID = UUID(), entries: [CommandHistoryEntry] = []) {
self.sessionID = sessionID
values = Array(entries.filter { $0.sessionID == sessionID }.prefix(Self.perSessionLimit)).map {
var entry = $0
entry.command = CommandHistoryEntry.normalizingLineContinuations(in: entry.command)
return entry
}
guard !query.isEmpty else { return normalized }
return normalized.filter { $0.command.localizedCaseInsensitiveContains(query) }
}
public func entries(matching query: String = "") -> [CommandHistoryEntry] {
query.isEmpty ? values : values.filter { $0.command.localizedCaseInsensitiveContains(query) }
}
@discardableResult
public func record(
_ command: String, for hostID: Host.ID, at date: Date = Date()
) -> CommandHistoryEntry? {
public func record(_ command: String, at date: Date = Date()) -> CommandHistoryEntry? {
let command = CommandHistoryEntry.normalizingLineContinuations(in: command)
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let entry = CommandHistoryEntry(hostID: hostID, command: command, executedAt: date)
do {
try repository.insert(entry, limit: Self.perHostLimit)
storageError = nil
} catch {
storageError = error.localizedDescription
return nil
}
guard !isClosed, !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!ShellHistoryFilter.isIntegrationCommand(command)
else { return nil }
let entry = CommandHistoryEntry(sessionID: sessionID, command: command, executedAt: date)
values.insert(entry, at: 0)
trim()
revision &+= 1
return entry
}
@discardableResult
public func recordCompletion(
for id: CommandHistoryEntry.ID, hostID: Host.ID,
exitStatus: Int32, duration: TimeInterval?
for id: CommandHistoryEntry.ID, exitStatus: Int32, duration: TimeInterval?
) -> CommandHistoryEntry? {
do {
guard var entry = try repository.entries(for: hostID).first(where: { $0.id == id })
guard !isClosed, let index = values.firstIndex(where: { $0.id == id }) else { return nil }
values[index].exitStatus = exitStatus
values[index].duration = duration.flatMap { $0.isFinite ? max(0, $0) : nil }
revision &+= 1
return values[index]
}
public func load(using loader: any ShellHistoryLoading, source: ShellHistorySource?) {
guard !isClosed, !hasLoaded, loadTask == nil else { return }
let token = UUID()
loadID = token
isLoading = true
loadError = nil
loadTask = Task { [weak self] in
do {
let snapshot = try await loader.load(source: source)
guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return }
self.merge(snapshot)
self.hasLoaded = true
self.finishLoading()
} catch {
guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return }
self.loadError = error.localizedDescription
self.finishLoading()
}
}
}
/// Disconnect cancels only the current read; reconnect retains a completed snapshot.
public func cancelLoading() {
loadID = nil
loadTask?.cancel()
finishLoading()
}
/// Closing is terminal, including while a closed pane remains in the reopen stack.
public func close() {
isClosed = true
cancelLoading()
values.removeAll()
loadError = nil
revision &+= 1
}
private func finishLoading() {
loadTask = nil
isLoading = false
}
private func merge(_ snapshot: [ShellHistoryRecord]) {
// Consume matches one at a time: repeated executions must not collapse into a set.
var unmatched = values
let imported = snapshot.reversed().compactMap { record -> CommandHistoryEntry? in
let command = CommandHistoryEntry.normalizingLineContinuations(in: record.command)
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!ShellHistoryFilter.isIntegrationCommand(command)
else { return nil }
entry.exitStatus = exitStatus
entry.duration = duration.map { max(0, $0) }
try repository.update(entry)
storageError = nil
revision &+= 1
return entry
} catch {
storageError = error.localizedDescription
return nil
}
}
public func delete(_ id: CommandHistoryEntry.ID) {
do {
try repository.delete(id)
storageError = nil
} catch {
storageError = error.localizedDescription
return
if let date = record.executedAt,
let index = unmatched.firstIndex(where: {
$0.command == command
&& $0.executedAt.map {
floor($0.timeIntervalSince1970) == floor(date.timeIntervalSince1970)
} == true
})
{
unmatched.remove(at: index)
return nil
}
return CommandHistoryEntry(
sessionID: sessionID, command: command, executedAt: record.executedAt, duration: record.duration)
}
values.append(contentsOf: imported)
trim()
revision &+= 1
}
public func clear(for hostID: Host.ID) {
do {
try repository.clear(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return
}
revision &+= 1
private func trim() {
if values.count > Self.perSessionLimit { values.removeLast(values.count - Self.perSessionLimit) }
}
}
@@ -1,81 +1,37 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
/// Incremental persistence boundary for SFTP browse history.
///
/// Preview and focused-test instances use the in-memory projection. Each app
/// supplies a persistent repository so successful directory visits and file
/// downloads remain available across sessions without entering Vault sync.
/// Successful browse/open operations from one live SFTP session only.
@MainActor
@Observable
public final class SFTPHistoryModel {
public static let perHostLimit = HistoryLimits.retainedEntries
private let repository: any SFTPHistoryRepository
public static let perSessionLimit = HistoryLimits.retainedEntries
public let sessionID: UUID
public private(set) var revision: UInt64 = 0
public private(set) var storageError: String?
private var values: [SFTPHistoryEntry]
private var isClosed = false
public init(
entries: [SFTPHistoryEntry] = [],
repository: (any SFTPHistoryRepository)? = nil
) {
self.repository = repository ?? InMemorySFTPHistoryRepository(entries: entries)
do {
try self.repository.trim(to: Self.perHostLimit)
} catch {
storageError = error.localizedDescription
}
public init(sessionID: UUID = UUID(), entries: [SFTPHistoryEntry] = []) {
self.sessionID = sessionID
values = Array(entries.filter { $0.sessionID == sessionID }.prefix(Self.perSessionLimit))
}
public func entries(for hostID: Host.ID, matching query: String = "") -> [SFTPHistoryEntry] {
let values: [SFTPHistoryEntry]
do {
values = try repository.entries(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return []
}
guard !query.isEmpty else { return values }
return values.filter { $0.path.localizedCaseInsensitiveContains(query) }
public func entries(matching query: String = "") -> [SFTPHistoryEntry] {
query.isEmpty ? values : values.filter { $0.path.localizedCaseInsensitiveContains(query) }
}
public func record(
kind: SFTPHistoryEntry.Kind, path: String, for hostID: Host.ID,
at date: Date = Date()
) {
public func record(kind: SFTPHistoryEntry.Kind, path: String, at date: Date = Date()) {
guard !isClosed else { return }
let path = RemotePath.normalized(path)
do {
try repository.upsert(
SFTPHistoryEntry(hostID: hostID, kind: kind, path: path, visitedAt: date),
limit: Self.perHostLimit
)
storageError = nil
revision &+= 1
} catch {
storageError = error.localizedDescription
}
values.removeAll { $0.kind == kind && $0.path == path }
values.insert(SFTPHistoryEntry(sessionID: sessionID, kind: kind, path: path, visitedAt: date), at: 0)
if values.count > Self.perSessionLimit { values.removeLast(values.count - Self.perSessionLimit) }
revision &+= 1
}
public func delete(_ id: SFTPHistoryEntry.ID) {
do {
try repository.delete(id)
storageError = nil
revision &+= 1
} catch {
storageError = error.localizedDescription
}
}
public func clear(for hostID: Host.ID) {
do {
try repository.clear(for: hostID)
storageError = nil
revision &+= 1
} catch {
storageError = error.localizedDescription
}
public func close() {
isClosed = true
values.removeAll()
revision &+= 1
}
}
@@ -1,3 +1,3 @@
public enum HistoryLimits {
public nonisolated enum HistoryLimits {
public static let retainedEntries = 250
}
@@ -1,52 +0,0 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
@MainActor
public protocol CommandHistoryRepository: AnyObject {
func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry]
func trim(to limit: Int) throws
func insert(_ entry: CommandHistoryEntry, limit: Int) throws
func update(_ entry: CommandHistoryEntry) throws
func delete(_ id: CommandHistoryEntry.ID) throws
func clear(for hostID: Host.ID) throws
}
@MainActor
public final class InMemoryCommandHistoryRepository: CommandHistoryRepository {
private var values: [CommandHistoryEntry]
public init(entries: [CommandHistoryEntry] = []) { values = entries }
public func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] {
values.filter { $0.hostID == hostID }.sorted { $0.executedAt > $1.executedAt }
}
public func trim(to limit: Int) throws {
let entriesByHost = Dictionary(grouping: values, by: \.hostID)
let retained = Set(
entriesByHost.values.flatMap { entries in
entries.sorted { $0.executedAt > $1.executedAt }.prefix(limit).map(\.id)
})
values.removeAll { !retained.contains($0.id) }
}
public func insert(_ entry: CommandHistoryEntry, limit: Int) throws {
values.append(entry)
try trim(to: limit)
}
public func update(_ entry: CommandHistoryEntry) throws {
guard let index = values.firstIndex(where: { $0.id == entry.id }) else { return }
values[index] = entry
}
public func delete(_ id: CommandHistoryEntry.ID) throws {
values.removeAll { $0.id == id }
}
public func clear(for hostID: Host.ID) throws {
values.removeAll { $0.hostID == hostID }
}
}
@@ -1,49 +0,0 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
@MainActor
public protocol SFTPHistoryRepository: AnyObject {
func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry]
func trim(to limit: Int) throws
func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws
func delete(_ id: SFTPHistoryEntry.ID) throws
func clear(for hostID: Host.ID) throws
}
@MainActor
public final class InMemorySFTPHistoryRepository: SFTPHistoryRepository {
private var values: [SFTPHistoryEntry]
public init(entries: [SFTPHistoryEntry] = []) { values = entries }
public func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
values.filter { $0.hostID == hostID }.sorted { $0.visitedAt > $1.visitedAt }
}
public func trim(to limit: Int) throws {
let entriesByHost = Dictionary(grouping: values, by: \.hostID)
let retained = Set(
entriesByHost.values.flatMap { entries in
entries.sorted { $0.visitedAt > $1.visitedAt }.prefix(limit).map(\.id)
})
values.removeAll { !retained.contains($0.id) }
}
public func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
values.removeAll {
$0.hostID == entry.hostID && $0.kind == entry.kind && $0.path == entry.path
}
values.append(entry)
try trim(to: limit)
}
public func delete(_ id: SFTPHistoryEntry.ID) throws {
values.removeAll { $0.id == id }
}
public func clear(for hostID: Host.ID) throws {
values.removeAll { $0.hostID == hostID }
}
}
@@ -0,0 +1,5 @@
import TermindDomain
public nonisolated protocol ShellHistoryLoading: Sendable {
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord]
}
@@ -1,24 +1,21 @@
import Foundation
/// One command the remote shell confirmed it was about to execute.
///
/// History is deliberately its own entity rather than a field on `Host`: a
/// busy terminal can append frequently without rewriting host configuration.
/// A command in one live terminal session. File records may have no execution timestamp.
public nonisolated struct CommandHistoryEntry: Identifiable, Hashable, Sendable {
public var id: UUID = UUID()
public var hostID: Host.ID
public var sessionID: UUID
public var command: String
public var executedAt: Date = Date()
public var executedAt: Date?
/// `nil` means the shell never reported a completion event, including rows
/// written by versions that predate command lifecycle reporting.
public var exitStatus: Int32?
public var duration: TimeInterval?
public init(
id: UUID = UUID(), hostID: Host.ID, command: String, executedAt: Date = Date(), exitStatus: Int32? = nil,
id: UUID = UUID(), sessionID: UUID, command: String, executedAt: Date? = nil, exitStatus: Int32? = nil,
duration: TimeInterval? = nil
) {
self.id = id
self.hostID = hostID
self.sessionID = sessionID
self.command = command
self.executedAt = executedAt
self.exitStatus = exitStatus
@@ -0,0 +1,193 @@
import Foundation
public nonisolated struct ShellHistorySource: Sendable, Equatable {
public enum Shell: String, Sendable { case bash, zsh, fish }
public var shell: Shell
/// nil asks for the shell's default; empty explicitly disables file history.
public var path: String?
public init(shell: Shell, path: String? = nil) {
self.shell = shell
self.path = path
}
}
public nonisolated struct ShellHistoryRecord: Sendable, Equatable {
public var command: String
public var executedAt: Date?
public var duration: TimeInterval?
public init(command: String, executedAt: Date? = nil, duration: TimeInterval? = nil) {
self.command = command
self.executedAt = executedAt
self.duration = duration
}
}
public nonisolated enum ShellHistoryFilter {
public static func isIntegrationCommand(_ command: String) -> Bool {
let text = command.trimmingCharacters(in: .whitespacesAndNewlines)
// Anchored signatures, not substring matches: echo/grep of these names is user work.
if text.hasPrefix("command true '__TRMD_"), text.contains("_PROBE__'") { return true }
if text.hasPrefix("if [[ ") || text.hasPrefix("if not set -q "),
text.contains("__termind_integration_token"),
text.contains("__termind_report_prompt"), text.contains("_DONE__")
{
return true
}
let name = text.prefix { $0.isLetter || $0.isNumber || $0 == "_" }
return [
"__termind_report_command", "__termind_report_prompt", "__termind_report_cwd",
"__termind_report_finished", "__termind_prompt_begin", "__termind_prompt_end",
"__termind_compose_draft", "__termind_compose_clear",
].contains(String(name))
}
}
/// Parses complete records in file order. It never invents timestamps or evaluates shell text.
public nonisolated enum ShellHistoryParser {
public static func parse(
_ text: String, shell: ShellHistorySource.Shell, truncatedStart: Bool = false
) -> [ShellHistoryRecord] {
var lines = text.components(separatedBy: "\n")
// A file being appended may end halfway through a record. Only newline-terminated text is complete.
if !lines.isEmpty { lines.removeLast() }
if truncatedStart, !lines.isEmpty { lines.removeFirst() }
let records: [ShellHistoryRecord]
switch shell {
case .bash: records = bash(lines, truncatedStart: truncatedStart)
case .zsh: records = zsh(lines, truncatedStart: truncatedStart)
case .fish: records = fish(lines)
}
return records.filter {
!$0.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !ShellHistoryFilter.isIntegrationCommand($0.command)
}
}
private static func timestamp(_ text: String) -> Date? {
guard !text.isEmpty, text.allSatisfy(\.isNumber), let seconds = Double(text),
seconds.isFinite, seconds > 0
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
private static func bash(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] {
let hasMarkers = lines.contains { $0.hasPrefix("#") && timestamp(String($0.dropFirst())) != nil }
if !hasMarkers { return plain(lines) }
var records: [ShellHistoryRecord] = []
var date: Date?
var command: [String] = []
var accepts = !truncatedStart
func flush() {
if accepts, !command.isEmpty {
records.append(ShellHistoryRecord(command: command.joined(separator: "\n"), executedAt: date))
}
command.removeAll()
}
for line in lines {
if line.hasPrefix("#"), let next = timestamp(String(line.dropFirst())) {
flush()
date = next
accepts = true
} else {
command.append(line)
}
}
flush()
return records
}
private static func plain(_ lines: [String]) -> [ShellHistoryRecord] {
var result: [ShellHistoryRecord] = []
var pending = ""
for line in lines {
pending += line
if trailingBackslashes(line).isMultiple(of: 2) {
result.append(ShellHistoryRecord(command: pending))
pending = ""
} else {
pending += "\n"
}
}
return result
}
private static func trailingBackslashes(_ line: String) -> Int {
line.reversed().prefix { $0 == "\\" }.count
}
private static func zsh(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] {
var result: [ShellHistoryRecord] = []
var pending: ShellHistoryRecord?
var continuing = false
let extended = lines.contains { header($0) != nil }
var accepts = !truncatedStart || !extended
for line in lines {
if !continuing {
if let parsed = header(line) {
pending = parsed
accepts = true
} else if accepts {
pending = ShellHistoryRecord(command: line)
}
} else {
pending?.command += "\n" + line
}
continuing = !trailingBackslashes(line).isMultiple(of: 2)
if continuing {
// Zsh uses backslash + physical newline to serialize a literal newline.
if pending != nil { pending!.command.removeLast() }
} else if let record = pending {
result.append(record)
pending = nil
}
}
return result
}
private static func header(_ line: String) -> ShellHistoryRecord? {
guard line.hasPrefix(": "), let semicolon = line.firstIndex(of: ";") else { return nil }
let fields = line[line.index(line.startIndex, offsetBy: 2)..<semicolon].split(separator: ":")
guard fields.count == 2, let date = timestamp(String(fields[0])),
let duration = Double(fields[1]), duration.isFinite, duration >= 0
else { return nil }
return ShellHistoryRecord(
command: String(line[line.index(after: semicolon)...]), executedAt: date, duration: duration)
}
private static func fish(_ lines: [String]) -> [ShellHistoryRecord] {
var records: [ShellHistoryRecord] = []
var current: ShellHistoryRecord?
for line in lines {
if line.hasPrefix("- cmd: ") {
if let current { records.append(current) }
current = ShellHistoryRecord(command: fishUnescape(String(line.dropFirst(7))))
} else if line.hasPrefix(" when: ") {
current?.executedAt = timestamp(String(line.dropFirst(8)))
}
}
if let current { records.append(current) }
return records
}
private static func fishUnescape(_ value: String) -> String {
var result = ""
var iterator = value.makeIterator()
while let character = iterator.next() {
if character == "\\", let next = iterator.next() {
if next == "n" {
result.append("\n")
} else if next == "\\" {
result.append("\\")
} else {
result.append("\\")
result.append(next)
}
} else {
result.append(character)
}
}
return result
}
}
@@ -10,7 +10,7 @@ public nonisolated struct SFTPHistoryEntry: Identifiable, Hashable, Sendable {
}
public var id: UUID = UUID()
public var hostID: Host.ID
public var sessionID: UUID
public var kind: Kind
public var path: String
public var visitedAt: Date = Date()
@@ -19,9 +19,9 @@ public nonisolated struct SFTPHistoryEntry: Identifiable, Hashable, Sendable {
if path == "/" { return "/" }
return (path as NSString).lastPathComponent
}
public init(id: UUID = UUID(), hostID: Host.ID, kind: Kind, path: String, visitedAt: Date = Date()) {
public init(id: UUID = UUID(), sessionID: UUID, kind: Kind, path: String, visitedAt: Date = Date()) {
self.id = id
self.hostID = hostID
self.sessionID = sessionID
self.kind = kind
self.path = path
self.visitedAt = visitedAt
@@ -1,81 +0,0 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
@MainActor
public final class SwiftDataCommandHistoryRepository: CommandHistoryRepository {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] {
try context.fetch(
FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.executedAt, order: .reverse)]
)
).map(\.value)
}
public func trim(to limit: Int) throws {
let rows = try context.fetch(
FetchDescriptor<PersistentCommandHistoryEntry>(
sortBy: [SortDescriptor(\.executedAt, order: .reverse)]
))
var counts: [Host.ID: Int] = [:]
let overflow = rows.filter { row in
let count = counts[row.hostID, default: 0]
counts[row.hostID] = count + 1
return count >= limit
}
guard !overflow.isEmpty else { return }
try context.commit {
for row in overflow { context.delete(row) }
}
}
public func insert(_ entry: CommandHistoryEntry, limit: Int) throws {
try context.commit {
let hostID = entry.hostID
var descriptor = FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.executedAt, order: .reverse)]
)
descriptor.fetchOffset = max(0, limit - 1)
for old in try context.fetch(descriptor) { context.delete(old) }
context.insert(PersistentCommandHistoryEntry(entry))
}
}
public func update(_ entry: CommandHistoryEntry) throws {
let id = entry.id
var descriptor = FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
guard let existing = try context.fetch(descriptor).first else { return }
try context.commit { existing.apply(entry) }
}
public func delete(_ id: CommandHistoryEntry.ID) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
if let entry = try context.fetch(descriptor).first { context.delete(entry) }
}
}
public func clear(for hostID: Host.ID) throws {
try context.commit {
try context.delete(
model: PersistentCommandHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
}
}
}
@@ -923,6 +923,16 @@ public enum TermindSchemaV48: VersionedSchema {
}
}
/// Removes local command and SFTP history; history now lives with each active session.
public enum TermindSchemaV49: VersionedSchema {
public static let versionIdentifier = Schema.Version(49, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV48.models.filter {
$0 != PersistentCommandHistoryEntry.self && $0 != PersistentSFTPHistoryEntry.self
}
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -938,7 +948,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self,
TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self,
TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self,
TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self,
TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self,
]
}
@@ -1046,6 +1056,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV45.self, toVersion: TermindSchemaV46.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV46.self, toVersion: TermindSchemaV47.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV47.self, toVersion: TermindSchemaV48.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV48.self, toVersion: TermindSchemaV49.self),
]
}
}
@@ -12,11 +12,10 @@ public enum TermindSchemaV9Models {
public var command = ""
public var executedAt = Date(timeIntervalSince1970: 0)
public init(_ value: CommandHistoryEntry) {
id = value.id
hostID = value.hostID
command = value.command
executedAt = value.executedAt
public init(hostID: UUID, command: String, executedAt: Date) {
self.hostID = hostID
self.command = command
self.executedAt = executedAt
}
}
}
@@ -30,23 +29,9 @@ public final class PersistentCommandHistoryEntry {
public var exitStatus: Int32?
public var duration: TimeInterval?
public init(_ value: CommandHistoryEntry) {
apply(value)
}
public func apply(_ value: CommandHistoryEntry) {
id = value.id
hostID = value.hostID
command = value.command
executedAt = value.executedAt
exitStatus = value.exitStatus
duration = value.duration
}
public var value: CommandHistoryEntry {
CommandHistoryEntry(
id: id, hostID: hostID, command: command, executedAt: executedAt,
exitStatus: exitStatus, duration: duration
)
public init(hostID: UUID, command: String, executedAt: Date) {
self.hostID = hostID
self.command = command
self.executedAt = executedAt
}
}
@@ -116,14 +116,6 @@ public final class SwiftDataHostRepository: HostRepository {
snippet.targetHostIDs.removeAll { $0 == hostID }
}
for rule in try forwardingRules(for: hostID) { context.delete(rule) }
try context.delete(
model: PersistentCommandHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
try context.delete(
model: PersistentSFTPHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
try HistorySyncJournal(context: context).bootstrapIfNeeded()
try context.delete(
model: PersistentConnectionHistoryEntry.self,
@@ -1,110 +1,18 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
@Model
public final class PersistentSFTPHistoryEntry {
@Attribute(.unique) public var id: UUID = UUID()
public var hostID: UUID = UUID()
public var kind = SFTPHistoryEntry.Kind.directory.rawValue
public var kind = "directory"
public var path = "/"
public var visitedAt = Date(timeIntervalSince1970: 0)
public init(_ value: SFTPHistoryEntry) {
id = value.id
hostID = value.hostID
kind = value.kind.rawValue
path = value.path
visitedAt = value.visitedAt
}
public var value: SFTPHistoryEntry {
SFTPHistoryEntry(
id: id, hostID: hostID,
kind: SFTPHistoryEntry.Kind(rawValue: kind) ?? .directory,
path: path, visitedAt: visitedAt
)
}
}
@MainActor
public final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
).map(\.value)
}
public func trim(to limit: Int) throws {
let rows = try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
))
var counts: [Host.ID: Int] = [:]
let overflow = rows.filter { row in
let count = counts[row.hostID, default: 0]
counts[row.hostID] = count + 1
return count >= limit
}
guard !overflow.isEmpty else { return }
try context.commit {
for row in overflow { context.delete(row) }
}
}
public func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
let hostID = entry.hostID
try context.commit {
let rows = try context.fetch(
FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
))
if let row = rows.first(where: {
$0.kind == entry.kind.rawValue && $0.path == entry.path
}) {
row.visitedAt = entry.visitedAt
} else {
context.insert(PersistentSFTPHistoryEntry(entry))
}
}
try context.commit {
var overflow = FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
overflow.fetchOffset = limit
for row in try context.fetch(overflow) { context.delete(row) }
}
}
public func delete(_ id: SFTPHistoryEntry.ID) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentSFTPHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first { context.delete(row) }
}
}
public func clear(for hostID: Host.ID) throws {
try context.commit {
try context.delete(
model: PersistentSFTPHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
}
public init(hostID: UUID, kind: String, path: String, visitedAt: Date) {
self.hostID = hostID
self.kind = kind
self.path = path
self.visitedAt = visitedAt
}
}
@@ -20,7 +20,7 @@ public enum TermindPersistence {
#endif
// Local schema migrations do not change the independently versioned CloudKit envelopes.
public static let schema = Schema(versionedSchema: TermindSchemaV48.self)
public static let schema = Schema(versionedSchema: TermindSchemaV49.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -10,11 +10,10 @@ public nonisolated enum CommandHistoryPresentation {
}
public static func subtitle(for entry: CommandHistoryEntry, locale: Locale) -> String {
var parts = [
entry.executedAt.formatted(
.relative(presentation: .numeric).locale(locale)
)
]
var parts: [String] = []
if let date = entry.executedAt {
parts.append(date.formatted(.relative(presentation: .numeric).locale(locale)))
}
if let duration = entry.duration, let formatted = formattedDuration(duration) {
parts.append(formatted)
}
@@ -179,10 +179,6 @@ extension AppStrings {
"Choose an edited copy with the same file format as the original."
}
public static var clearFileHistoryForThisHostConfirmation: LocalizedStringResource {
"Clear File History for this Host?"
}
public static var clearFinished: LocalizedStringResource {
"Clear Finished"
}
@@ -12,10 +12,6 @@ extension AppStrings {
"Charset Error"
}
public static var clearHistoryConfirmation: LocalizedStringResource {
"Clear History?"
}
public static var connectionLog: LocalizedStringResource {
"Connection Log"
}
@@ -189,19 +185,12 @@ extension AppStrings {
"Write serial port"
}
public static var clearCommandHistoryWarning: LocalizedStringResource {
"This permanently removes all saved commands for the current Host."
}
public static var commandHistoryRequirement: LocalizedStringResource {
"This session is not recording history. Enable Shell Integration and use bash, zsh, or fish."
"Enable Shell Integration to add commands to this session’s history in real time."
}
public static var clearCommandHistoryForThisHostConfirmation: LocalizedStringResource {
"Clear command history for this Host?"
}
public static var commandsConfirmedByTheRemoteShellAppearHere: LocalizedStringResource {
"Commands confirmed by the remote shell appear here."
"Shell history and commands from this session appear here."
}
public static var copyLogs: LocalizedStringResource {
"Copy Logs"
@@ -0,0 +1,104 @@
import Foundation
import TermindApplication
import TermindDomain
/// A bounded, read-only exec operation on the terminal's authenticated connection.
public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
public static let maximumFileBytes = 2 * 1024 * 1024
private let execute: @Sendable (String, Int) async throws -> SSHExecResult
private let resolveSource: @Sendable () async throws -> ShellHistorySource?
private let charset: RemoteCharset
public init(channel: SSHPTYChannel, charset: RemoteCharset) {
self.init(
charset: charset,
resolveSource: { try await channel.historySource() },
execute: { try await channel.executeHistoryRead($0, outputLimit: $1) })
}
init(
charset: RemoteCharset,
resolveSource: @escaping @Sendable () async throws -> ShellHistorySource?,
execute: @escaping @Sendable (String, Int) async throws -> SSHExecResult
) {
self.charset = charset
self.resolveSource = resolveSource
self.execute = execute
}
public func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
try await withThrowingTaskGroup(of: [ShellHistoryRecord].self) { group in
group.addTask { try await read(source: source) }
group.addTask {
try await Task.sleep(for: .seconds(10))
throw TermindSSHError.library("History read timed out.")
}
defer { group.cancelAll() }
return try await group.next() ?? []
}
}
private func read(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
let resolved: ShellHistorySource?
if let source { resolved = source } else { resolved = try await resolveSource() }
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let result = try await execute(Self.command(source: resolved, token: token), 3 * 1024 * 1024)
guard result.exitCode == 0, !result.stdoutTruncated else {
throw TermindSSHError.library("History file could not be read.")
}
guard let start = result.stdout.range(of: "__TRMD_HISTORY_\(token)__"),
let lineEnd = result.stdout[start.upperBound...].firstIndex(of: "\n"),
let end = result.stdout.range(of: "__TRMD_HISTORY_END_\(token)__", range: lineEnd..<result.stdout.endIndex)
else { throw TermindSSHError.library("History response was incomplete.") }
let fields = result.stdout[start.upperBound..<lineEnd].split(separator: ";", omittingEmptySubsequences: false)
guard fields.count == 2, let size = Int(fields[1]), size >= 0 else {
throw TermindSSHError.library("History response was invalid.")
}
if size == 0 { return [] }
guard let shell = ShellHistorySource.Shell(rawValue: String(fields[0])),
var bytes = Data(
base64Encoded: String(result.stdout[lineEnd..<end.lowerBound]), options: .ignoreUnknownCharacters),
bytes.count <= Self.maximumFileBytes
else { throw TermindSSHError.library("History response was invalid.") }
if shell == .zsh { bytes = Self.unmetafy(bytes) }
let text = try RemoteCharsetStreamConverter.decode(bytes, charset: charset)
return Array(
ShellHistoryParser.parse(text, shell: shell, truncatedStart: size > Self.maximumFileBytes)
.suffix(HistoryLimits.retainedEntries))
}
static func command(source: ShellHistorySource?, token: String) -> String {
let shell = source.map { quote($0.shell.rawValue) } ?? "\"${SHELL##*/}\""
let path =
source?.path.map { "file=\(quote($0));" }
?? #"case "$kind" in bash) file=${HISTFILE-$HOME/.bash_history} ;; zsh) file=${HISTFILE-${ZDOTDIR-$HOME}/.zsh_history} ;; fish) name=${fish_history-fish}; if [ -n "$name" ]; then file=${XDG_DATA_HOME-$HOME/.local/share}/fish/${name}_history; else file=; fi ;; *) file= ;; esac;"#
// The file is data, never shell source. Base64 preserves zsh metafied and non-UTF-8 bytes
// across the ordinary exec charset decoder. Framing excludes login/profile chatter.
return """
kind=\(shell); \(path)
if [ -z "$file" ] || [ ! -e "$file" ]; then
printf '__TRMD_HISTORY_\(token)__%s;0\\n__TRMD_HISTORY_END_\(token)__\\n' "$kind"; exit 0
fi
[ -f "$file" ] && [ -r "$file" ] || exit 1
exec 3< "$file" || exit 1
size=$(wc -c < "$file") || exit 1
size=$(printf '%s' "$size" | tr -d '[:space:]')
printf '__TRMD_HISTORY_\(token)__%s;%s\\n' "$kind" "$size"
tail -c \(maximumFileBytes) <&3 | base64 || exit 1
printf '\\n__TRMD_HISTORY_END_\(token)__\\n'
"""
}
private static func quote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
static func unmetafy(_ input: Data) -> Data {
var output = Data()
var iterator = input.makeIterator()
while let byte = iterator.next() {
if byte == 0x83, let next = iterator.next() { output.append(next ^ 0x20) } else { output.append(byte) }
}
return output
}
}
@@ -12,87 +12,6 @@ import TermindDomain
/// commands Session-level concurrency without violating libssh2's single-owner
/// requirement or letting a quiet/blocked command pin its peers.
nonisolated final class SSHConnectionWorker: @unchecked Sendable {
private final class StableCString: @unchecked Sendable {
let pointer: UnsafeMutablePointer<CChar>
let count: Int
init(_ data: Data) {
let bytes = Array(data)
count = bytes.count
pointer = .allocate(capacity: count + 1)
for (index, byte) in bytes.enumerated() { pointer[index] = CChar(bitPattern: byte) }
pointer[count] = 0
}
deinit { pointer.deallocate() }
}
private var agentForwardingRequested = false
private var agentRequestOwner: UUID?
private final class SSHExecChannel: @unchecked Sendable {
enum Phase { case opening, forwarding, starting, running, closing, freeing }
let id = UUID()
let command: StableCString
let cancellation: TransferCancellationToken
let deadline: ContinuousClock.Instant
let outputLimit: Int
let onOutput: SSHExecOutputHandler?
let continuation: CheckedContinuation<SSHExecResult, any Error>
let channelLease: SSHChannelLease
let input = SSHByteQueue(chunkSize: 32_700, zeroizesConsumedBytes: true)
var phase = Phase.opening
var execChannel: OpaquePointer?
var sentEOF = false
var stdout = Data()
var stderr = Data()
var stdoutRemoteBytes = 0
var stderrRemoteBytes = 0
let stdoutDecoder: RemoteCharsetStreamConverter
let stderrDecoder: RemoteCharsetStreamConverter
let charset: RemoteCharset
var stdoutTruncated = false
var stderrTruncated = false
var terminalError: (any Error)?
var exitCode: Int32 = 0
var signal: String?
var started = false
var completed = false
init(
command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset,
timeout: TimeInterval,
outputLimit: Int, cancellation: TransferCancellationToken,
onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease,
continuation: CheckedContinuation<SSHExecResult, any Error>
) throws {
let effective: String
if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty {
effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))"
} else {
effective = "/bin/sh -lc \(Self.shellQuote(command))"
}
self.command = StableCString(
try RemoteCharsetStreamConverter.encode(effective, charset: charset))
self.charset = charset
stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
self.cancellation = cancellation
deadline = .now.advanced(by: .seconds(max(1, min(timeout, 15 * 60))))
self.outputLimit = outputLimit
self.onOutput = onOutput
self.channelLease = channelLease
self.continuation = continuation
try stdin?.append(to: input, charset: charset)
sentEOF = input.isEmpty
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
private let logger: Logger
@@ -104,16 +23,12 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
private var timer: (any DispatchSourceTimer)?
private var hostID: UUID?
private var charset: RemoteCharset = .utf8
private var commands: [UUID: SSHExecChannel] = [:]
private var pendingOpens = SSHReadyQueue<UUID>()
private var opening: SSHExecChannel?
private var ready = SSHReadyQueue<UUID>()
private var scheduled = false
private var generation = 0
private var controlDirections: Int32 = 0
private var keepaliveDue = true
private var nextKeepalive = DispatchTime.now()
private var scratch = [CChar](repeating: 0, count: 16 * 1024)
private var execChannelOwner: SSHMultiplexedExecChannelOwner!
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
private var connectionFailure: (@Sendable (String) -> Void)?
private var didReportConnectionFailure = false
@@ -126,6 +41,21 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
self.logger = logger
self.maxSessionsPolicy = maxSessionsPolicy
queue.setSpecific(key: queueKey, value: ())
execChannelOwner = SSHMultiplexedExecChannelOwner(
queue: queue, logger: logger,
context: { [weak self] in
guard let self, let connection = self.connection, let buffer = self.buffer,
let channelBudget = self.channelBudget
else { return nil }
return .init(
connection: connection, buffer: buffer, channelBudget: channelBudget,
charset: self.charset, hostID: self.hostID)
},
schedulePump: { [weak self] in self?.schedulePump() },
connectionFailed: { [weak self] error in
guard let self, let connection = self.connection else { return }
self.failConnection(connection.libssh2Session, error: error)
})
sftpChannelOwner = SSHMultiplexedSFTPChannelOwner(
queue: queue,
context: { [weak self] in
@@ -215,57 +145,9 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw TermindSSHError.library("The command is empty.")
}
let operationID = UUID().uuidString.lowercased()
var metadata: Logger.Metadata = [
"operation_id": "\(operationID)", "protocol": "ssh", "stage": "executing",
]
if let hostID = try? await queue.perform({ self.hostID }) {
metadata["host_id"] = RemoteLogging.hostID(hostID)
}
logger.debug("ssh.exec.started", metadata: metadata)
do {
guard let channelBudget = try await queue.perform({ self.channelBudget }) else {
throw SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected)
}
let channelLease = try await channelBudget.acquire(cancellation: cancellation)
let result = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<SSHExecResult, any Error>) in
queue.async { [self] in
guard connection != nil, self.channelBudget === channelBudget else {
channelLease.release()
continuation.resume(
throwing: SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected))
return
}
let context: SSHExecChannel
do {
context = try SSHExecChannel(
command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
onOutput: onOutput, channelLease: channelLease, continuation: continuation)
} catch {
channelLease.release()
continuation.resume(throwing: error)
return
}
commands[context.id] = context
pendingOpens.insert(context.id)
schedulePump()
}
}
metadata["outcome"] = "success"
metadata["stage"] = "completed"
logger.debug("ssh.exec.completed", metadata: metadata)
return result
} catch {
metadata["error_code"] = RemoteLogging.errorCode(error)
metadata["outcome"] = "failure"
logger.error("ssh.exec.failed", metadata: metadata)
throw error
}
try await execChannelOwner.execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, onOutput: onOutput)
}
func openSFTPChannel() async throws -> any SFTPChannelProtocol {
@@ -309,7 +191,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
}
private func wakeAll() {
for id in commands.keys { ready.insert(id) }
execChannelOwner.wakeAllOnQueue()
schedulePump()
}
@@ -343,18 +225,10 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
deadline: min(
deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2),
permitsIO: { !buffer.isBackpressured }) ?? false
if advanceOpen(session) { progressed = true }
while !buffer.isBackpressured,
DispatchTime.now().uptimeNanoseconds < deadline,
let id = ready.pop()
{
guard let command = commands[id] else { continue }
if advance(command, session: session, deadline: deadline) { progressed = true }
}
if execChannelOwner.advanceOnQueue(deadline: deadline) { progressed = true }
if buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) == true {
progressed = true
}
if advanceOpen(session) { progressed = true }
if keepaliveDue {
var next: Int32 = 0
let result = libssh2_keepalive_send(session, &next)
@@ -379,323 +253,17 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
failConnection(session)
return
}
let directions = commands.values.reduce(controlDirections) { partial, command in
guard command.execChannel != nil else { return partial }
return partial | libssh2_session_block_directions(session)
}
let directions = controlDirections | execChannelOwner.controlDirections
socketEvents?.update(
read: !commands.isEmpty && !buffer.isBackpressured,
read: !execChannelOwner.isEmptyOnQueue && !buffer.isBackpressured,
write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0)
if progressed || buffer.exhaustedReadBudget { wakeAll() }
}
private func advanceOpen(_ session: OpaquePointer) -> Bool {
if opening == nil {
while let id = pendingOpens.pop() {
if let command = commands[id], command.phase == .opening {
opening = command
break
}
}
}
guard let command = opening else { return false }
if cancellationError(for: command) != nil {
opening = nil
finish(command, throwing: cancellationError(for: command)!)
return true
}
guard
let execChannel = libssh2_channel_open_ex(
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0)
else {
let code = libssh2_session_last_errno(session)
if code == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
opening = nil
let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to open SSH exec Channel")
if isConnectionError(code) {
failConnection(session, error: error)
} else {
finish(command, throwing: SSHCommandExecutionError.channelUnavailable(error))
}
return true
}
opening = nil
command.execChannel = execChannel
command.phase = buffer?.agentChannels == nil ? .starting : .forwarding
ready.insert(command.id)
return true
}
private func advance(_ command: SSHExecChannel, session: OpaquePointer, deadline: UInt64) -> Bool {
if let cancellation = cancellationError(for: command), command.terminalError == nil {
if agentRequestOwner == command.id {
// A cancelled, unanswered connection-wide request cannot safely
// be repeated on another channel: the peer may already have accepted it.
failConnection(session, error: cancellation)
return false
}
command.terminalError = cancellation
if command.execChannel == nil {
finish(command, throwing: cancellation)
return true
}
command.phase = .closing
}
guard let execChannel = command.execChannel else { return false }
switch command.phase {
case .opening:
return false
case .forwarding:
if agentForwardingRequested {
command.phase = .starting
ready.insert(command.id)
return true
}
guard agentRequestOwner == nil || agentRequestOwner == command.id else { return false }
agentRequestOwner = command.id
let result = libssh2_channel_request_auth_agent(execChannel)
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
agentRequestOwner = nil
if result != 0 {
command.terminalError = SSHCommandExecutionError.channelUnavailable(
SSHConnectionOpener.connectionError(session, prefix: "Agent Forwarding request failed"))
command.phase = .closing
} else {
agentForwardingRequested = true
command.phase = .starting
}
ready.insert(command.id)
return true
case .starting:
let result = libssh2_channel_process_startup(
execChannel, "exec", 4, command.command.pointer, UInt32(command.command.count))
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
guard result == 0 else {
let code = libssh2_session_last_errno(session)
let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to start remote command")
command.terminalError = SSHCommandExecutionError.channelUnavailable(error)
command.phase = .closing
if isConnectionError(code) { failConnection(session, error: error) }
ready.insert(command.id)
return true
}
command.started = true
command.phase = .running
ready.insert(command.id)
return true
case .running:
return advanceRunning(command, execChannel: execChannel, session: session, deadline: deadline)
case .closing:
let result = libssh2_channel_close(execChannel)
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
if command.terminalError == nil {
command.exitCode = libssh2_channel_get_exit_status(execChannel)
command.signal = exitSignal(execChannel: execChannel, session: session)
}
command.phase = .freeing
ready.insert(command.id)
return true
case .freeing:
let result = libssh2_channel_free(execChannel)
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
command.execChannel = nil
if let error = command.terminalError {
finish(command, throwing: error)
} else {
let stdout = String(decoding: command.stdout, as: UTF8.self)
let stderr = String(decoding: command.stderr, as: UTF8.self)
finish(
command,
returning: SSHExecResult(
stdout: stdout, stderr: stderr,
exitCode: command.exitCode, signal: command.signal,
stdoutTruncated: command.stdoutTruncated,
stderrTruncated: command.stderrTruncated))
}
return true
}
}
private func advanceRunning(
_ command: SSHExecChannel, execChannel: OpaquePointer, session: OpaquePointer, deadline: UInt64
) -> Bool {
var progressed = false
var remaining = SSHForwardingTuning.current.quantum
if !command.input.isEmpty {
let written = command.input.withFirstBytes {
libssh2_channel_write_ex(
execChannel, 0, $0.baseAddress!.assumingMemoryBound(to: CChar.self), $0.count)
}
if written > 0 {
command.input.consume(written)
remaining -= min(remaining, written)
progressed = true
} else if written == LIBSSH2_ERROR_EAGAIN {
blocked(session)
} else {
return fail(command, session: session, prefix: "Unable to write command input")
}
}
if command.input.isEmpty, !command.sentEOF {
let result = libssh2_channel_send_eof(execChannel)
if result == 0 {
command.sentEOF = true
progressed = true
} else if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
} else {
return fail(command, session: session, prefix: "Unable to finish command input")
}
}
for stream in [Int32(0), Int32(1)] {
while remaining > 0 && DispatchTime.now().uptimeNanoseconds < deadline {
let capacity = min(scratch.count, remaining)
let count = libssh2_channel_read_ex(execChannel, stream, &scratch, capacity)
if count > 0 {
progressed = true
remaining -= count
let chunk = Data(bytes: scratch, count: count)
do {
try appendDecoded(chunk, stream: stream, to: command)
} catch {
command.terminalError = error
command.phase = .closing
ready.insert(command.id)
return true
}
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
if count == LIBSSH2_ERROR_EAGAIN { blocked(session) }
break
} else {
return fail(command, session: session, prefix: "Unable to read command output")
}
}
}
if libssh2_channel_eof(execChannel) == 1 {
do {
try finishDecodedOutput(command)
} catch {
command.terminalError = error
}
command.phase = .closing
ready.insert(command.id)
return true
}
if !command.input.isEmpty || !command.sentEOF || libssh2_poll_channel_read(execChannel, 0) == 1 {
ready.insert(command.id)
}
return progressed
}
private func appendDecoded(_ chunk: Data, stream: Int32, to command: SSHExecChannel) throws {
let isError = stream != 0
let consumed = isError ? command.stderrRemoteBytes : command.stdoutRemoteBytes
let available = max(0, command.outputLimit - consumed)
let accepted = Data(chunk.prefix(available))
if isError {
command.stderrRemoteBytes += accepted.count
if accepted.count < chunk.count { command.stderrTruncated = true }
} else {
command.stdoutRemoteBytes += accepted.count
if accepted.count < chunk.count { command.stdoutTruncated = true }
}
guard !accepted.isEmpty else { return }
let decoded = try (isError ? command.stderrDecoder : command.stdoutDecoder).convert(accepted).data
guard !decoded.isEmpty else { return }
command.onOutput?(decoded, isError)
if isError { command.stderr.append(decoded) } else { command.stdout.append(decoded) }
}
private func finishDecodedOutput(_ command: SSHExecChannel) throws {
if !command.stdoutTruncated {
let tail = try command.stdoutDecoder.finish().data
if !tail.isEmpty {
command.onOutput?(tail, false)
command.stdout.append(tail)
}
}
if !command.stderrTruncated {
let tail = try command.stderrDecoder.finish().data
if !tail.isEmpty {
command.onOutput?(tail, true)
command.stderr.append(tail)
}
}
}
private func fail(_ command: SSHExecChannel, session: OpaquePointer, prefix: String) -> Bool {
let code = libssh2_session_last_errno(session)
let error = SSHConnectionOpener.connectionError(session, prefix: prefix)
if isConnectionError(code) {
failConnection(session, error: error)
} else {
command.terminalError = error
command.phase = .closing
ready.insert(command.id)
}
return true
}
private func cancellationError(for command: SSHExecChannel) -> (any Error)? {
if command.cancellation.isCancelled { return CancellationError() }
if ContinuousClock.now >= command.deadline {
return TermindSSHError.library("The remote command timed out.")
}
return nil
}
private func exitSignal(execChannel: OpaquePointer, session: OpaquePointer) -> String? {
var pointer: UnsafeMutablePointer<CChar>?
var length = 0
let result = libssh2_channel_get_exit_signal(execChannel, &pointer, &length, nil, nil, nil, nil)
guard result == 0, let pointer else { return nil }
defer { libssh2_free(session, pointer) }
return String(
decoding: UnsafeBufferPointer(start: pointer, count: length).map(UInt8.init(bitPattern:)),
as: UTF8.self)
}
private func finish(_ command: SSHExecChannel, returning result: SSHExecResult) {
guard !command.completed else { return }
command.completed = true
commands[command.id] = nil
command.continuation.resume(returning: result)
}
private func finish(_ command: SSHExecChannel, throwing error: any Error) {
guard !command.completed else { return }
command.completed = true
commands[command.id] = nil
command.continuation.resume(throwing: error)
}
private func blocked(_ session: OpaquePointer) {
controlDirections |= libssh2_session_block_directions(session)
}
private func isConnectionError(_ code: Int32) -> Bool {
[
LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV,
LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT,
LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_BAD_SOCKET,
].contains(code)
}
private func failConnection(_ session: OpaquePointer, error: (any Error)? = nil) {
let failure = error ?? SSHConnectionOpener.connectionError(session, prefix: "The SSH connection closed")
if !didReportConnectionFailure {
@@ -707,17 +275,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
private func cleanup(error: any Error) {
buffer?.agentChannels?.shutdown()
agentForwardingRequested = false
agentRequestOwner = nil
generation += 1
scheduled = false
timer?.cancel()
timer = nil
for command in Array(commands.values) { finish(command, throwing: error) }
commands.removeAll()
opening = nil
pendingOpens = SSHReadyQueue()
ready = SSHReadyQueue()
execChannelOwner.closeAllOnQueue(error: error)
sftpChannelOwner.closeAllOnQueue()
channelBudget?.close()
channelBudget = nil
@@ -0,0 +1,550 @@
import CSSH2
import Darwin
import Foundation
import Logging
import TermindApplication
import TermindDomain
/// Shared exec state machines. All libssh2 calls run on the enclosing connection's queue.
nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable {
struct Context: @unchecked Sendable {
var connection: SSHAuthenticatedConnection
var buffer: SSHConnectionBuffer
var channelBudget: SSHChannelBudget
var charset: RemoteCharset
var hostID: UUID?
}
private let queue: DispatchQueue
private let logger: Logger
private let context: @Sendable () -> Context?
private let schedulePump: @Sendable () -> Void
private let connectionFailed: @Sendable (any Error) -> Void
private let didCloseLastChannel: @Sendable () -> Void
private let requestsAgentForwarding: Bool
private var buffer: SSHConnectionBuffer? { context()?.buffer }
private var charset: RemoteCharset { context()?.charset ?? .utf8 }
private var isClosingAll = false
private var commands: [UUID: SSHExecChannel] = [:]
private var pendingOpens = SSHReadyQueue<UUID>()
private var opening: SSHExecChannel?
private var ready = SSHReadyQueue<UUID>()
private var scratch = [CChar](repeating: 0, count: 16 * 1024)
private(set) var controlDirections: Int32 = 0
var isEmptyOnQueue: Bool { commands.isEmpty }
init(
queue: DispatchQueue, logger: Logger, requestsAgentForwarding: Bool = true,
context: @escaping @Sendable () -> Context?,
schedulePump: @escaping @Sendable () -> Void,
connectionFailed: @escaping @Sendable (any Error) -> Void,
didCloseLastChannel: @escaping @Sendable () -> Void = {}
) {
self.queue = queue
self.logger = logger
self.requestsAgentForwarding = requestsAgentForwarding
self.context = context
self.schedulePump = schedulePump
self.connectionFailed = connectionFailed
self.didCloseLastChannel = didCloseLastChannel
}
func wakeAllOnQueue() {
for id in commands.keys { ready.insert(id) }
}
func advanceOnQueue(deadline: UInt64) -> Bool {
guard let context = context() else { return false }
controlDirections = 0
let session = context.connection.libssh2Session
var progressed = advanceOpen(session)
while !context.buffer.isBackpressured, DispatchTime.now().uptimeNanoseconds < deadline,
let id = ready.pop()
{
guard let command = commands[id] else { continue }
if advance(command, session: session, deadline: deadline) { progressed = true }
}
if self.context() != nil, advanceOpen(session) { progressed = true }
return progressed
}
func cancelAllOnQueue() {
for command in commands.values { command.cancellation.cancel() }
wakeAllOnQueue()
}
func closeAllOnQueue(error: any Error) {
isClosingAll = true
defer { isClosingAll = false }
for command in Array(commands.values) { finish(command, throwing: error) }
commands.removeAll()
opening = nil
pendingOpens = SSHReadyQueue()
ready = SSHReadyQueue()
agentForwardingRequested = false
agentRequestOwner = nil
}
private func failConnection(_ session: OpaquePointer, error: (any Error)? = nil) {
connectionFailed(error ?? SSHConnectionOpener.connectionError(session, prefix: "The SSH connection closed"))
}
private final class StableCString: @unchecked Sendable {
let pointer: UnsafeMutablePointer<CChar>
let count: Int
init(_ data: Data) {
let bytes = Array(data)
count = bytes.count
pointer = .allocate(capacity: count + 1)
for (index, byte) in bytes.enumerated() { pointer[index] = CChar(bitPattern: byte) }
pointer[count] = 0
}
deinit { pointer.deallocate() }
}
private var agentForwardingRequested = false
private var agentRequestOwner: UUID?
private final class SSHExecChannel: @unchecked Sendable {
enum Phase { case opening, forwarding, starting, running, closing, freeing }
let id = UUID()
let command: StableCString
let cancellation: TransferCancellationToken
let deadline: ContinuousClock.Instant
let outputLimit: Int
let onOutput: SSHExecOutputHandler?
let continuation: CheckedContinuation<SSHExecResult, any Error>
let channelLease: SSHChannelLease
let input = SSHByteQueue(chunkSize: 32_700, zeroizesConsumedBytes: true)
var phase = Phase.opening
var execChannel: OpaquePointer?
var sentEOF = false
var stdout = Data()
var stderr = Data()
var stdoutRemoteBytes = 0
var stderrRemoteBytes = 0
let stdoutDecoder: RemoteCharsetStreamConverter
let stderrDecoder: RemoteCharsetStreamConverter
let charset: RemoteCharset
var stdoutTruncated = false
var stderrTruncated = false
var terminalError: (any Error)?
var exitCode: Int32 = 0
var signal: String?
var started = false
var completed = false
init(
command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset,
usesLoginShell: Bool,
timeout: TimeInterval,
outputLimit: Int, cancellation: TransferCancellationToken,
onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease,
continuation: CheckedContinuation<SSHExecResult, any Error>
) throws {
let shellFlags = usesLoginShell ? "-lc" : "-c"
let effective: String
if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty {
effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh \(shellFlags) \(Self.shellQuote(command))"
} else {
effective = "/bin/sh \(shellFlags) \(Self.shellQuote(command))"
}
self.command = StableCString(
try RemoteCharsetStreamConverter.encode(effective, charset: charset))
self.charset = charset
stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
self.cancellation = cancellation
deadline = .now.advanced(by: .seconds(max(1, min(timeout, 15 * 60))))
self.outputLimit = outputLimit
self.onOutput = onOutput
self.channelLease = channelLease
self.continuation = continuation
try stdin?.append(to: input, charset: charset)
sentEOF = input.isEmpty
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
func execute(
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?,
usesLoginShell: Bool = true
) async throws -> SSHExecResult {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw TermindSSHError.library("The command is empty.")
}
let operationID = UUID().uuidString.lowercased()
var metadata: Logger.Metadata = [
"operation_id": "\(operationID)", "protocol": "ssh", "stage": "executing",
]
if let hostID = try? await queue.perform({ self.context()?.hostID }) {
metadata["host_id"] = RemoteLogging.hostID(hostID)
}
logger.debug("ssh.exec.started", metadata: metadata)
do {
guard let channelBudget = try await queue.perform({ self.context()?.channelBudget }) else {
throw SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected)
}
let channelLease = try await channelBudget.acquire(cancellation: cancellation)
let result = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<SSHExecResult, any Error>) in
queue.async { [self] in
guard self.context() != nil, self.context()?.channelBudget === channelBudget else {
channelLease.release()
continuation.resume(
throwing: SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected))
return
}
let context: SSHExecChannel
do {
context = try SSHExecChannel(
command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset,
usesLoginShell: usesLoginShell,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
onOutput: onOutput, channelLease: channelLease, continuation: continuation)
} catch {
channelLease.release()
continuation.resume(throwing: error)
return
}
commands[context.id] = context
pendingOpens.insert(context.id)
schedulePump()
}
}
metadata["outcome"] = "success"
metadata["stage"] = "completed"
logger.debug("ssh.exec.completed", metadata: metadata)
return result
} catch {
metadata["error_code"] = RemoteLogging.errorCode(error)
metadata["outcome"] = "failure"
logger.error("ssh.exec.failed", metadata: metadata)
throw error
}
}
private func advanceOpen(_ session: OpaquePointer) -> Bool {
if opening == nil {
while let id = pendingOpens.pop() {
if let command = commands[id], command.phase == .opening {
opening = command
break
}
}
}
guard let command = opening else { return false }
if cancellationError(for: command) != nil {
opening = nil
finish(command, throwing: cancellationError(for: command)!)
return true
}
guard
let execChannel = libssh2_channel_open_ex(
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0)
else {
let code = libssh2_session_last_errno(session)
if code == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
opening = nil
let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to open SSH exec Channel")
if isConnectionError(code) {
failConnection(session, error: error)
} else {
finish(command, throwing: SSHCommandExecutionError.channelUnavailable(error))
}
return true
}
opening = nil
command.execChannel = execChannel
command.phase = requestsAgentForwarding && buffer?.agentChannels != nil ? .forwarding : .starting
ready.insert(command.id)
return true
}
private func advance(_ command: SSHExecChannel, session: OpaquePointer, deadline: UInt64) -> Bool {
// Release the caller promptly on cancellation/timeout; retain the channel and its
// lease until the nonblocking close/free state machine finishes on this queue.
if let cancellation = cancellationError(for: command), !command.completed {
command.completed = true
command.continuation.resume(throwing: command.terminalError ?? cancellation)
}
if let cancellation = cancellationError(for: command), command.terminalError == nil {
if agentRequestOwner == command.id {
// A cancelled, unanswered connection-wide request cannot safely
// be repeated on another channel: the peer may already have accepted it.
failConnection(session, error: cancellation)
return false
}
command.terminalError = cancellation
if command.execChannel == nil {
finish(command, throwing: cancellation)
return true
}
command.phase = .closing
}
guard let execChannel = command.execChannel else { return false }
switch command.phase {
case .opening:
return false
case .forwarding:
if agentForwardingRequested {
command.phase = .starting
ready.insert(command.id)
return true
}
guard agentRequestOwner == nil || agentRequestOwner == command.id else { return false }
agentRequestOwner = command.id
let result = libssh2_channel_request_auth_agent(execChannel)
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
agentRequestOwner = nil
if result != 0 {
command.terminalError = SSHCommandExecutionError.channelUnavailable(
SSHConnectionOpener.connectionError(session, prefix: "Agent Forwarding request failed"))
command.phase = .closing
} else {
agentForwardingRequested = true
command.phase = .starting
}
ready.insert(command.id)
return true
case .starting:
let result = libssh2_channel_process_startup(
execChannel, "exec", 4, command.command.pointer, UInt32(command.command.count))
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
guard result == 0 else {
let code = libssh2_session_last_errno(session)
let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to start remote command")
command.terminalError = SSHCommandExecutionError.channelUnavailable(error)
command.phase = .closing
if isConnectionError(code) { failConnection(session, error: error) }
ready.insert(command.id)
return true
}
command.started = true
command.phase = .running
ready.insert(command.id)
return true
case .running:
return advanceRunning(command, execChannel: execChannel, session: session, deadline: deadline)
case .closing:
let result = libssh2_channel_close(execChannel)
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
if command.terminalError == nil {
command.exitCode = libssh2_channel_get_exit_status(execChannel)
command.signal = exitSignal(execChannel: execChannel, session: session)
}
command.phase = .freeing
ready.insert(command.id)
return true
case .freeing:
let result = libssh2_channel_free(execChannel)
if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
return false
}
command.execChannel = nil
if let error = command.terminalError {
finish(command, throwing: error)
} else {
let stdout = String(decoding: command.stdout, as: UTF8.self)
let stderr = String(decoding: command.stderr, as: UTF8.self)
finish(
command,
returning: SSHExecResult(
stdout: stdout, stderr: stderr,
exitCode: command.exitCode, signal: command.signal,
stdoutTruncated: command.stdoutTruncated,
stderrTruncated: command.stderrTruncated))
}
return true
}
}
private func advanceRunning(
_ command: SSHExecChannel, execChannel: OpaquePointer, session: OpaquePointer, deadline: UInt64
) -> Bool {
var progressed = false
var remaining = SSHForwardingTuning.current.quantum
if !command.input.isEmpty {
let written = command.input.withFirstBytes {
libssh2_channel_write_ex(
execChannel, 0, $0.baseAddress!.assumingMemoryBound(to: CChar.self), $0.count)
}
if written > 0 {
command.input.consume(written)
remaining -= min(remaining, written)
progressed = true
} else if written == LIBSSH2_ERROR_EAGAIN {
blocked(session)
} else {
return fail(command, session: session, prefix: "Unable to write command input")
}
}
if command.input.isEmpty, !command.sentEOF {
let result = libssh2_channel_send_eof(execChannel)
if result == 0 {
command.sentEOF = true
progressed = true
} else if result == LIBSSH2_ERROR_EAGAIN {
blocked(session)
} else {
return fail(command, session: session, prefix: "Unable to finish command input")
}
}
for stream in [Int32(0), Int32(1)] {
while remaining > 0 && DispatchTime.now().uptimeNanoseconds < deadline {
let capacity = min(scratch.count, remaining)
let count = libssh2_channel_read_ex(execChannel, stream, &scratch, capacity)
if count > 0 {
progressed = true
remaining -= count
let chunk = Data(bytes: scratch, count: count)
do {
try appendDecoded(chunk, stream: stream, to: command)
} catch {
command.terminalError = error
command.phase = .closing
ready.insert(command.id)
return true
}
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
if count == LIBSSH2_ERROR_EAGAIN { blocked(session) }
break
} else {
return fail(command, session: session, prefix: "Unable to read command output")
}
}
}
if libssh2_channel_eof(execChannel) == 1 {
do {
try finishDecodedOutput(command)
} catch {
command.terminalError = error
}
command.phase = .closing
ready.insert(command.id)
return true
}
if !command.input.isEmpty || !command.sentEOF || libssh2_poll_channel_read(execChannel, 0) == 1 {
ready.insert(command.id)
}
return progressed
}
private func appendDecoded(_ chunk: Data, stream: Int32, to command: SSHExecChannel) throws {
let isError = stream != 0
let consumed = isError ? command.stderrRemoteBytes : command.stdoutRemoteBytes
let available = max(0, command.outputLimit - consumed)
let accepted = Data(chunk.prefix(available))
if isError {
command.stderrRemoteBytes += accepted.count
if accepted.count < chunk.count { command.stderrTruncated = true }
} else {
command.stdoutRemoteBytes += accepted.count
if accepted.count < chunk.count { command.stdoutTruncated = true }
}
guard !accepted.isEmpty else { return }
let decoded = try (isError ? command.stderrDecoder : command.stdoutDecoder).convert(accepted).data
guard !decoded.isEmpty else { return }
command.onOutput?(decoded, isError)
if isError { command.stderr.append(decoded) } else { command.stdout.append(decoded) }
}
private func finishDecodedOutput(_ command: SSHExecChannel) throws {
if !command.stdoutTruncated {
let tail = try command.stdoutDecoder.finish().data
if !tail.isEmpty {
command.onOutput?(tail, false)
command.stdout.append(tail)
}
}
if !command.stderrTruncated {
let tail = try command.stderrDecoder.finish().data
if !tail.isEmpty {
command.onOutput?(tail, true)
command.stderr.append(tail)
}
}
}
private func fail(_ command: SSHExecChannel, session: OpaquePointer, prefix: String) -> Bool {
let code = libssh2_session_last_errno(session)
let error = SSHConnectionOpener.connectionError(session, prefix: prefix)
if isConnectionError(code) {
failConnection(session, error: error)
} else {
command.terminalError = error
command.phase = .closing
ready.insert(command.id)
}
return true
}
private func cancellationError(for command: SSHExecChannel) -> (any Error)? {
if command.cancellation.isCancelled { return CancellationError() }
if ContinuousClock.now >= command.deadline {
return TermindSSHError.library("The remote command timed out.")
}
return nil
}
private func exitSignal(execChannel: OpaquePointer, session: OpaquePointer) -> String? {
var pointer: UnsafeMutablePointer<CChar>?
var length = 0
let result = libssh2_channel_get_exit_signal(execChannel, &pointer, &length, nil, nil, nil, nil)
guard result == 0, let pointer else { return nil }
defer { libssh2_free(session, pointer) }
return String(
decoding: UnsafeBufferPointer(start: pointer, count: length).map(UInt8.init(bitPattern:)),
as: UTF8.self)
}
private func finish(_ command: SSHExecChannel, returning result: SSHExecResult) {
commands[command.id] = nil
if !command.completed {
command.completed = true
command.continuation.resume(returning: result)
}
if commands.isEmpty, !isClosingAll { didCloseLastChannel() }
}
private func finish(_ command: SSHExecChannel, throwing error: any Error) {
commands[command.id] = nil
if !command.completed {
command.completed = true
command.continuation.resume(throwing: error)
}
if commands.isEmpty, !isClosingAll { didCloseLastChannel() }
}
private func blocked(_ session: OpaquePointer) {
controlDirections |= libssh2_session_block_directions(session)
}
private func isConnectionError(_ code: Int32) -> Bool {
[
LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV,
LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT,
LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_BAD_SOCKET,
].contains(code)
}
}
@@ -36,6 +36,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
public var shell: String?
public var systemInfo: RemoteSystemInfo?
public var homeDirectory: String?
public var historySource: ShellHistorySource?
public var homeResolutionUnavailable = false
var diagnostic: Diagnostic?
}
@@ -240,12 +241,13 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
var visible = preservedOutput
visible.append(Data(buffer[buffer.index(after: lineEnd)...]))
let homeDirectory = parseHomeDirectory(in: buffer)
let historySource = parseHistorySource(in: buffer)
finish()
return Update(
visibleOutput: visible, isComplete: true,
integrationInstalled: true, shell: detectedShell,
systemInfo: detectedSystemInfo,
homeDirectory: homeDirectory,
homeDirectory: homeDirectory, historySource: historySource,
diagnostic: .installed(
late: installationStartedLate, homeResolved: homeDirectory != nil,
systemInfoAvailable: detectedSystemInfo != nil
@@ -253,6 +255,19 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
)
}
private func parseHistorySource(in data: Data) -> ShellHistorySource? {
guard let shell = detectedShell.flatMap(ShellHistorySource.Shell.init(rawValue:)) else { return nil }
let prefix = Data("__TRMD_\(token)_HISTORY__".utf8)
guard let start = data.range(of: prefix)?.upperBound,
let end = data[start...].firstIndex(of: 0x0A)
else { return nil }
var payload = Data(data[start..<end])
if payload.last == 0x0D { payload.removeLast() }
let path = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset)
guard !path.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return nil }
return ShellHistorySource(shell: shell, path: path)
}
private mutating func parseHomeDirectory(in data: Data) -> String? {
let prefix = Data("__TRMD_\(token)_HOME__".utf8)
guard let prefixRange = data.range(of: prefix),
@@ -367,7 +382,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
isComplete: update.isComplete,
integrationInstalled: update.integrationInstalled,
shell: update.shell, systemInfo: update.systemInfo,
homeDirectory: update.homeDirectory,
homeDirectory: update.homeDirectory, historySource: update.historySource,
homeResolutionUnavailable: update.homeResolutionUnavailable,
diagnostic: update.diagnostic
)
@@ -429,6 +444,8 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
private func installationCommand(for shell: String?) -> Data? {
let marker =
"builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'"
let historyMarker =
"__termind_history_path=\"${HISTFILE-}\"; if [[ -n \"$__termind_history_path\" && \"$__termind_history_path\" != /* ]]; then __termind_history_path=\"$PWD/$__termind_history_path\"; fi; builtin printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' \"$(__termind_encode \"$__termind_history_path\")\"; unset __termind_history_path; "
let command: String
switch shell {
case "bash":
@@ -463,17 +480,17 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
"fi;",
"fi;",
"fi;",
"\(marker);",
"\(historyMarker)\(marker);",
"__termind_report_prompt",
].joined(separator: " ") + "\n"
case "zsh":
// Unlike bash, zsh reserves `status` as a read-only special parameter.
// Assigning it aborts this function and the remaining prompt hooks.
command =
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n"
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(historyMarker)\(marker); __termind_report_prompt\n"
case "fish":
command =
" if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n"
" if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; set -l __termind_hname fish; if set -q fish_history; set __termind_hname \"$fish_history\"; end; set -l __termind_hbase \"$HOME/.local/share\"; if set -q XDG_DATA_HOME; and test -n \"$XDG_DATA_HOME\"; set __termind_hbase \"$XDG_DATA_HOME\"; end; set -l __termind_hpath ''; if test -n \"$__termind_hname\"; set __termind_hpath \"$__termind_hbase/fish/$__termind_hname\"_history; end; printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' (string escape --style=url -- \"$__termind_hpath\"); set -e __termind_hname __termind_hbase __termind_hpath; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n"
default:
return nil
}
@@ -765,6 +782,19 @@ public actor SSHPTYChannel {
try await worker.openSFTPChannel()
}
public func executeHistoryRead(_ command: String, outputLimit: Int) async throws -> SSHExecResult {
let cancellation = TransferCancellationToken()
return try await withTaskCancellationHandler {
try await worker.executeHistoryRead(command, outputLimit: outputLimit, cancellation: cancellation)
} onCancel: {
cancellation.cancel()
}
}
public func historySource() async throws -> ShellHistorySource? {
try await worker.historySource()
}
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) {
worker.resize(
columns: columns, rows: rows,
@@ -806,6 +836,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private var workingDirectoryFilter: RemoteOSC7CharsetFilter?
private var didReportExit = false
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
private var execChannelOwner: SSHMultiplexedExecChannelOwner!
private var detectedHistorySource: ShellHistorySource?
init(
output: @escaping SSHPTYChannel.OutputHandler,
@@ -821,6 +853,26 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
self.logger = logger
shellIntegrationLifecycleLogger = RemoteShellIntegrationLifecycleLogger(logger: logger)
self.maxSessionsPolicy = maxSessionsPolicy
execChannelOwner = SSHMultiplexedExecChannelOwner(
queue: queue, logger: logger, requestsAgentForwarding: false,
context: { [weak self] in
guard let self, let connection = self.connection, let buffer = self.buffer,
let channelBudget = self.channelBudget
else { return nil }
return .init(
connection: connection, buffer: buffer, channelBudget: channelBudget,
charset: self.charset, hostID: self.hostID)
},
schedulePump: { [weak self] in self?.pump() },
connectionFailed: { [weak self] error in
guard let self else { return }
self.reportExit(.failure((error as? TermindSSHError) ?? .disconnected))
self.cleanup()
},
didCloseLastChannel: { [weak self] in
guard let self, self.ptyChannel == nil, self.sftpChannelOwner.isEmptyOnQueue else { return }
self.cleanup()
})
sftpChannelOwner = SSHMultiplexedSFTPChannelOwner(
queue: queue,
context: { [weak self] in
@@ -836,7 +888,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
self?.updateSharedConnectionEvents(session: session, buffer: buffer)
},
didCloseLastChannel: { [weak self] in
guard let self, self.ptyChannel == nil else { return }
guard let self, self.ptyChannel == nil, self.execChannelOwner.isEmptyOnQueue else { return }
self.cleanup()
})
}
@@ -1568,6 +1620,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
socketEvents?.update(read: false, write: true)
return
}
execChannelOwner.wakeAllOnQueue()
let execProgress = execChannelOwner.advanceOnQueue(
deadline: min(
deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 4))
guard self.connection != nil else { return }
if execProgress { queue.async { [weak self] in self?.pump() } }
var agentProgress =
buffer.agentChannels?.advance(
deadline: min(
@@ -1680,6 +1738,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
if let command = update.command {
pendingWrites.append(command)
}
if let source = update.historySource { detectedHistorySource = source }
if update.integrationInstalled {
shellIntegrationHandler(
.available(
@@ -1756,6 +1815,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
private func closeTerminalSession() {
execChannelOwner.cancelAllOnQueue()
detectedHistorySource = nil
buffer?.agentChannels?.shutdown()
finishCharsetOutput()
pendingWrites.removeAll()
@@ -1775,7 +1836,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
ptyChannelLease?.release()
ptyChannelLease = nil
}
if sftpChannelOwner.isEmptyOnQueue { cleanup() }
if sftpChannelOwner.isEmptyOnQueue, execChannelOwner.isEmptyOnQueue {
cleanup()
} else {
queue.async { [weak self] in self?.pump() }
}
}
private func cleanup() {
@@ -1799,6 +1864,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
_ = libssh2_channel_free(ptyChannel)
self.ptyChannel = nil
}
execChannelOwner.closeAllOnQueue(error: CancellationError())
detectedHistorySource = nil
sftpChannelOwner.closeAllOnQueue()
ptyChannelLease?.release()
ptyChannelLease = nil
@@ -1959,6 +2026,27 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
nonisolated extension SSHPTYChannelWorker {
fileprivate func executeHistoryRead(
_ command: String, outputLimit: Int, cancellation: TransferCancellationToken
) async throws -> SSHExecResult {
try await execChannelOwner.execute(
command, workingDirectory: nil, stdin: nil, timeout: 10,
outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false)
}
fileprivate func historySource() async throws -> ShellHistorySource? {
let deadline = ContinuousClock.now.advanced(by: .seconds(6))
while true {
try Task.checkCancellation()
let (source, pending) = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
return (detectedHistorySource, shellIntegration != nil)
}
if !pending || source != nil || ContinuousClock.now >= deadline { return source }
try await Task.sleep(for: .milliseconds(50))
}
}
fileprivate func openSFTPChannel() async throws -> any SFTPChannelProtocol {
try await sftpChannelOwner.openChannel()
}
-10
View File
@@ -125,8 +125,6 @@ final class MobileAppModel {
let sshSettings: SSHSettingsModel
let sftpSettings: SFTPSettingsModel
let workspaceSettings: WorkspaceSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
let connectionHistory: ConnectionHistoryModel
let devices: DeviceDirectoryModel
let vaultSync: VaultSyncCoordinator?
@@ -247,10 +245,6 @@ final class MobileAppModel {
sftpSettings = SFTPSettingsModel(persistence: settingsPersistence)
workspaceSettings = WorkspaceSettingsModel(persistence: settingsPersistence)
connectionCoordinator.updateSSHPreferences(sshSettings.preferences)
commandHistory = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(container)))
sftpHistory = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container)))
let historyPersistence = HistorySyncPersistence(context: vaultContext)
try historyPersistence.prepare()
let connectionHistoryRepository = SwiftDataConnectionHistoryRepository(context: vaultContext)
@@ -378,8 +372,6 @@ final class MobileAppModel {
closeWorkspaces: { [weak self] payload in
guard let self else { return }
closeWorkspaces(for: payload.hostID)
commandHistory.clear(for: payload.hostID)
sftpHistory.clear(for: payload.hostID)
connectionHistory.clear(for: payload.hostID)
library.projectHostDeletion(payload.hostID)
},
@@ -672,8 +664,6 @@ final class MobileAppModel {
}
if change.isRemoved {
closeWorkspaces(for: change.id)
commandHistory.clear(for: change.id)
sftpHistory.clear(for: change.id)
connectionHistory.clear(for: change.id)
metrics.remove(change.id)
}
+5 -8
View File
@@ -183,8 +183,7 @@ final class MobileWorkspaceSessionModel {
hostID: host.id, title: host.name, kind: kind),
hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings, commandHistory: app.commandHistory,
sftpHistory: app.sftpHistory, connectionServices: app.workspaceConnectionServices,
terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices,
transientHost: host,
connectionRoute: request.kind == .telnet ? nil : route,
usernameHint: request.username,
@@ -238,7 +237,7 @@ final class MobileWorkspaceSessionModel {
if let workspace = workspaces.first(where: { $0.id == id }) {
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.disconnect()
workspace.close()
}
workspaces.removeAll { $0.id == id }
savedWorkspaceDescriptors.removeAll { $0.id == id }
@@ -264,7 +263,7 @@ final class MobileWorkspaceSessionModel {
app.workspaceDidClose(workspace.id)
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.disconnect()
workspace.close()
}
}
@@ -280,7 +279,7 @@ final class MobileWorkspaceSessionModel {
app.workspaceDidClose(workspace.id)
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.disconnect()
workspace.close()
}
}
@@ -297,8 +296,7 @@ final class MobileWorkspaceSessionModel {
let workspace = MobileWorkspace(
descriptor: descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings, commandHistory: app.commandHistory, sftpHistory: app.sftpHistory,
connectionServices: app.workspaceConnectionServices)
terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices)
addWorkspaces([workspace])
return workspace
}
@@ -338,7 +336,6 @@ final class MobileWorkspaceSessionModel {
hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings,
commandHistory: app.commandHistory, sftpHistory: app.sftpHistory,
connectionServices: app.workspaceConnectionServices,
initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory,
@@ -44,15 +44,13 @@ final class MobileSFTPChannelModel {
}
@ObservationIgnored private var fileSystem: (any RemoteFileSystem)?
@ObservationIgnored private let hostID: Host.ID?
@ObservationIgnored private let history: SFTPHistoryModel?
@ObservationIgnored private var connectionGeneration: UUID?
@ObservationIgnored private var directoryRequest = UUID()
@ObservationIgnored private var handlesConnectionError: ((any Error) -> Bool)?
@ObservationIgnored private var didChangePath: (() -> Void)?
init(hostID: Host.ID? = nil, history: SFTPHistoryModel? = nil) {
self.hostID = hostID
init(history: SFTPHistoryModel? = nil) {
self.history = history
}
@@ -239,7 +237,6 @@ final class MobileSFTPChannelModel {
}
private func recordDirectory(_ path: String) {
guard let hostID else { return }
history?.record(kind: .directory, path: path, for: hostID)
history?.record(kind: .directory, path: path)
}
}
+1 -36
View File
@@ -9,12 +9,11 @@ struct MobileSFTPHistoryView: View {
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var clearing = false
private var entries: [SFTPHistoryEntry] {
_ = workspace.sftpHistory.revision
return MobileHistoryPresentation.limited(
workspace.sftpHistory.entries(for: workspace.descriptor.hostID, matching: query)
workspace.sftpHistory.entries(matching: query)
)
}
@@ -43,52 +42,18 @@ struct MobileSFTPHistoryView: View {
reopen(entry)
}
.disabled(!workspace.connected)
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
workspace.sftpHistory.delete(entry.id)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
workspace.sftpHistory.delete(entry.id)
}
}
}
.listStyle(.insetGrouped)
}
}
.background(Color(uiColor: .systemGroupedBackground))
.safeAreaInset(edge: .bottom) {
if let error = workspace.sftpHistory.storageError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
}
.searchable(text: $query.singleLineText, prompt: AppStrings.Common.searchHistory.swiftUIKey)
.navigationTitle(AppStrings.Common.history.swiftUIKey)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppStrings.Common.done.swiftUIKey, systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
ToolbarItem(placement: .primaryAction) {
Button(AppStrings.Common.clear.swiftUIKey, systemImage: "trash", role: .destructive) {
clearing = true
}
.labelStyle(.iconOnly)
.disabled(entries.isEmpty)
}
}
.confirmationDialog(
AppStrings.SFTP.clearFileHistoryForThisHostConfirmation, isPresented: $clearing,
titleVisibility: .visible
) {
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) {
workspace.sftpHistory.clear(for: workspace.descriptor.hostID)
}
}
}
.mobileSheetPresentation(compact: true)
@@ -18,14 +18,13 @@ struct MobileCommandHistoryView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.locale) private var locale
@State private var query = ""
@State private var clearing = false
@State private var snippetEntry: CommandHistoryEntry?
@State private var snippetName = ""
private var entries: [CommandHistoryEntry] {
_ = workspace.commandHistory.revision
return MobileHistoryPresentation.limited(
workspace.commandHistory.entries(for: workspace.descriptor.hostID, matching: query)
workspace.commandHistory.entries(matching: query)
)
}
@@ -56,51 +55,18 @@ struct MobileCommandHistoryView: View {
Button(AppStrings.Snippets.saveAsSnippet.swiftUIKey, systemImage: "curlybraces") {
beginSavingSnippet(entry)
}
Divider()
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
workspace.commandHistory.delete(entry.id)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
workspace.commandHistory.delete(entry.id)
}
}
}
.listStyle(.insetGrouped)
}
}
.background(Color(uiColor: .systemGroupedBackground))
.safeAreaInset(edge: .bottom) {
if let error = workspace.commandHistory.storageError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
}
.searchable(text: $query.singleLineText)
.navigationTitle(AppStrings.Common.history.swiftUIKey)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppStrings.Common.done.swiftUIKey, systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
ToolbarItem(placement: .primaryAction) {
Button(AppStrings.Common.clear.swiftUIKey, systemImage: "trash", role: .destructive) {
clearing = true
}.labelStyle(.iconOnly)
}
}
.confirmationDialog(
AppStrings.Terminal.clearHistoryConfirmation, isPresented: $clearing,
titleVisibility: .visible
) {
Button(AppStrings.Common.clear.swiftUIKey, role: .destructive) {
workspace.commandHistory.clear(for: workspace.descriptor.hostID)
}
}
.alert(
AppStrings.Snippets.saveAsSnippet,
@@ -133,10 +99,10 @@ struct MobileCommandHistoryView: View {
.font(.body.monospaced())
.lineLimit(1)
.textSelection(.enabled)
Text(CommandHistoryPresentation.subtitle(for: entry, locale: locale))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
let subtitle = CommandHistoryPresentation.subtitle(for: entry, locale: locale)
if !subtitle.isEmpty {
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer(minLength: 8)
MobileTerminalRiskIndicator(assessment: CommandRiskClassifier.assess(entry.command))
+24 -13
View File
@@ -167,7 +167,9 @@ final class MobileWorkspace: Identifiable {
private var sshEventContinuation: AsyncStream<MobileSSHSessionEvent>.Continuation?
private var sshEventTask: Task<Void, Never>?
private var attempt: Task<Void, Never>?
private var generation = UUID()
private var generation = UUID() {
didSet { commandHistory.cancelLoading() }
}
private let hosts: any RemoteSessionHostStore
private let connectionServices: MobileWorkspaceConnectionServices
var securityKeySigning: SecurityKeySigningCoordinator { connection.securityKeySigning }
@@ -248,8 +250,6 @@ final class MobileWorkspace: Identifiable {
sftpEdits: SFTPEditService? = nil,
terminalHighlightSettings: TerminalHighlightSettingsModel = TerminalHighlightSettingsModel(),
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
commandHistory: CommandHistoryModel = CommandHistoryModel(),
sftpHistory: SFTPHistoryModel = SFTPHistoryModel(),
connectionServices: MobileWorkspaceConnectionServices = .live,
reconnectController: SessionReconnectController = SessionReconnectController(),
initialCommand: String? = nil,
@@ -265,9 +265,9 @@ final class MobileWorkspace: Identifiable {
) {
self.terminalHighlightSettings = terminalHighlightSettings
self.terminalSettings = terminalSettings
self.commandHistory = commandHistory
self.sftpHistory = sftpHistory
sftpChannel = MobileSFTPChannelModel(hostID: descriptor.hostID, history: sftpHistory)
commandHistory = CommandHistoryModel(sessionID: descriptor.id)
sftpHistory = SFTPHistoryModel(sessionID: descriptor.id)
sftpChannel = MobileSFTPChannelModel(history: sftpHistory)
self.transfers = transfers
self.sftpEdits = sftpEdits
self.descriptor = descriptor
@@ -606,7 +606,7 @@ final class MobileWorkspace: Identifiable {
commandExcludedFromHistory = nil
activeHistoryID = nil
} else {
activeHistoryID = commandHistory.record(command, for: descriptor.hostID)?.id
activeHistoryID = commandHistory.record(command)?.id
}
case .commandFinished(let status, let duration):
// CommandFinished is emitted from the prompt hook immediately before
@@ -620,7 +620,7 @@ final class MobileWorkspace: Identifiable {
submitPendingInitialCommandIfReady()
guard let id = activeHistoryID else { return }
commandHistory.recordCompletion(
for: id, hostID: descriptor.hostID, exitStatus: status, duration: duration)
for: id, exitStatus: status, duration: duration)
activeHistoryID = nil
case .draft(let draft):
commandDraft.receive(draft)
@@ -864,6 +864,10 @@ final class MobileWorkspace: Identifiable {
proxySubmissions.removeAll()
let wasReconnecting = connection.markConnected()
connected = true
if let ssh {
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
}
status = String(localized: AppStrings.Connections.connected)
journey.succeed()
if wasReconnecting, descriptor.kind == .ssh {
@@ -1241,7 +1245,14 @@ final class MobileWorkspace: Identifiable {
}
}
func close() {
commandHistory.close()
sftpHistory.close()
disconnect()
}
func disconnect() {
commandHistory.cancelLoading()
commandMonitor.endConnection()
cancelPendingUpload()
stopSSHEventConsumer()
@@ -1601,9 +1612,9 @@ final class MobileWorkspace: Identifiable {
connection: .context(transferContext),
homeDirectory: sftpChannel.homeDirectory,
coordinator: transfers
) { [sftpHistory, hostID = descriptor.hostID] path, state in
) { [sftpHistory] path, state in
guard state == .completed else { return }
sftpHistory.record(kind: .file, path: path, for: hostID)
sftpHistory.record(kind: .file, path: path)
}
} catch { operationError = error.localizedDescription }
return
@@ -1741,7 +1752,7 @@ final class MobileWorkspace: Identifiable {
file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp,
homeDirectory: sftpChannel.homeDirectory)
if connectionOrigin.allowsPersistence {
sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID)
sftpHistory.record(kind: .file, path: file.path)
}
completion(url)
} catch { operationError = error.localizedDescription }
@@ -1761,10 +1772,10 @@ final class MobileWorkspace: Identifiable {
remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID,
connection: .context(transferContext), scopedRoot: directory, overwrite: false,
homeDirectory: sftpChannel.homeDirectory
) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in
) { [sftpHistory, path = file.path] state in
guard state == .completed else { return }
if self.connectionOrigin.allowsPersistence {
sftpHistory.record(kind: .file, path: path, for: hostID)
sftpHistory.record(kind: .file, path: path)
}
completion(localURL)
}
+19 -18
View File
@@ -92,21 +92,20 @@ struct MobileAppTests {
#expect(first.assistant.conversation.composer.draft.isEmpty)
}
@Test func historyModelsUseTheSharedSwiftDataContainer() throws {
@Test func historyBelongsToWorkspaceAndIsReleasedOnClose() throws {
let app = try MobileAppModel(inMemory: true)
let hostID = UUID()
app.commandHistory.record("echo persisted", for: hostID)
app.sftpHistory.record(kind: .directory, path: "/srv/app", for: hostID)
let commands = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(app.container))
)
let files = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(app.container))
)
#expect(commands.entries(for: hostID).map(\.command) == ["echo persisted"])
#expect(files.entries(for: hostID).map(\.path) == ["/srv/app"])
let host = TermindDomain.Host(name: "Host", host: "example.invalid")
let first = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
let second = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
first.commandHistory.record("pwd")
first.sftpHistory.record(kind: .directory, path: "/srv")
#expect(second.commandHistory.entries().isEmpty)
#expect(second.sftpHistory.entries().isEmpty)
first.close()
#expect(first.commandHistory.entries().isEmpty)
#expect(first.sftpHistory.entries().isEmpty)
}
@Test func clearingRecentsPreservesCommandAndFileHistory() throws {
@@ -115,14 +114,16 @@ struct MobileAppTests {
let host = TermindDomain.Host(name: "Recent", host: "recent.example.invalid")
app.library.upsert(host)
_ = app.connectionHistory.record(kind: .ssh, for: host.id)
app.commandHistory.record("pwd", for: host.id)
app.sftpHistory.record(kind: .directory, path: "/srv", for: host.id)
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
workspace.commandHistory.record("pwd")
workspace.sftpHistory.record(kind: .directory, path: "/srv")
app.connectionHistory.clear(for: host.id)
#expect(app.connectionHistory.entries(for: host.id).isEmpty)
#expect(app.commandHistory.entries(for: host.id).map(\.command) == ["pwd"])
#expect(app.sftpHistory.entries(for: host.id).map(\.path) == ["/srv"])
#expect(workspace.commandHistory.entries().map(\.command) == ["pwd"])
#expect(workspace.sftpHistory.entries().map(\.path) == ["/srv"])
}
}
@@ -104,11 +104,10 @@ struct MobileCommandGeneratorTests {
let settings = TerminalSettingsModel()
#expect(settings.set(global, for: \.shellIntegration))
let connections = MobileSSHCommandConnectionBox()
let history = CommandHistoryModel()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, terminalSettings: settings, commandHistory: history,
connectionServices: connections.services)
hosts: library, terminalSettings: settings, connectionServices: connections.services)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
@@ -125,7 +124,7 @@ struct MobileCommandGeneratorTests {
#expect((workspace.sameDirectoryLaunch != nil) == expected)
workspace.receiveShellEvent(.commandStarted("pwd"))
workspace.receiveShellEvent(.commandFinished(0, duration: 0.1))
#expect(history.entries(for: host.id).count == (expected ? 1 : 0))
#expect(history.entries().count == (expected ? 1 : 0))
#expect(connections.sessionCount == 1)
workspace.disconnect()
@@ -352,8 +351,8 @@ struct MobileCommandGeneratorTests {
])
#expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) })
#expect(await eventually { app.commandHistory.entries(for: host.id).count == 1 })
#expect(app.commandHistory.entries(for: host.id).first?.command == "pwd")
#expect(await eventually { workspace.commandHistory.entries().count == 1 })
#expect(workspace.commandHistory.entries().first?.command == "pwd")
#expect(workspace.pendingInitialCommand == nil)
}
@@ -430,13 +429,12 @@ struct MobileCommandGeneratorTests {
@Test func productionCallbacksStayOrderedAndIgnoreAnOldSession() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let history = CommandHistoryModel()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
commandHistory: history,
connectionServices: connections.services
)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected && connections.sessionCount == 1 })
@@ -448,7 +446,7 @@ struct MobileCommandGeneratorTests {
.commandStarted("pwd"),
])
#expect(await eventually { history.entries(for: host.id).count == 1 })
#expect(await eventually { history.entries().count == 1 })
#expect(!workspace.canGenerateFromCurrentDraft)
await connections.sendShellEvents([
@@ -456,7 +454,7 @@ struct MobileCommandGeneratorTests {
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let entry = try #require(history.entries(for: host.id).first)
let entry = try #require(history.entries().first)
#expect(entry.command == "pwd")
#expect(entry.exitStatus == 0)
#expect(entry.duration == 0.25)
@@ -474,7 +472,7 @@ struct MobileCommandGeneratorTests {
], session: 1)
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
#expect(!history.entries(for: host.id).contains { $0.command == "stale" })
#expect(!history.entries().contains { $0.command == "stale" })
}
@Test func disabledIntegrationIsPassedToTheTransportAndRemainsUnavailable() async throws {
@@ -506,13 +504,12 @@ struct MobileCommandGeneratorTests {
@Test func unsupportedShellRemainsUnavailableAfterOrderedPromptEvents() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let history = CommandHistoryModel()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
commandHistory: history,
connectionServices: connections.services
)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
@@ -525,7 +522,7 @@ struct MobileCommandGeneratorTests {
.promptReady,
])
#expect(await eventually { history.entries(for: host.id).count == 1 })
#expect(await eventually { history.entries().count == 1 })
#expect(!workspace.canGenerateFromCurrentDraft)
let generator = MobileCommandGeneratorStub(command: "echo generated")
workspace.generateCommand(using: generator)
@@ -1079,14 +1076,13 @@ struct MobileUploadBatchTests {
connectSFTP: { _, _, _ in sessionClient },
writeSSH: { _, _ in }
)
let history = SFTPHistoryModel()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
sftpHistory: history,
connectionServices: services
)
let history = workspace.sftpHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
@@ -1094,7 +1090,7 @@ struct MobileUploadBatchTests {
#expect(await workspace.reopenHistoryFile(at: report.path))
#expect(
await eventually {
history.entries(for: host.id).contains { $0.kind == .file && $0.path == report.path }
history.entries().contains { $0.kind == .file && $0.path == report.path }
})
#expect(await workspace.reopenHistoryFile(at: broken.path))
@@ -1105,12 +1101,12 @@ struct MobileUploadBatchTests {
if case .failed = state { return true }
return false
})
#expect(!history.entries(for: host.id).contains { $0.path == broken.path })
#expect(!history.entries().contains { $0.path == broken.path })
#expect(transfers.sftpTransfers.allSatisfy { $0.sourceHomeDirectory == "/home/test" })
#expect(!(await workspace.reopenHistoryFile(at: "/srv/missing.txt")))
#expect(!(await workspace.reopenHistoryFile(at: changedDirectory.path)))
#expect(!history.entries(for: host.id).contains { $0.path == changedDirectory.path })
#expect(!history.entries().contains { $0.path == changedDirectory.path })
}
@Test func sharedUploadPlannerDoesNotFollowASymlinkOutsideTheSelectedFolder() async throws {
+6 -8
View File
@@ -165,31 +165,29 @@ struct MobileSFTPChannelTests {
#expect(mobileSFTPListItems(path: "/", files: sorted).count == sorted.count)
}
@Test func successfulDirectoriesRefreshPersistentHistoryAndFailuresDoNot() async throws {
let hostID = UUID()
@Test func successfulDirectoriesRefreshSessionHistoryAndFailuresDoNot() async throws {
let history = SFTPHistoryModel()
let fileSystem = MobileBrowserFileSystem(
listings: [
"/home": [],
"/srv": [],
])
let channel = MobileSFTPChannelModel(hostID: hostID, history: history)
let channel = MobileSFTPChannelModel(history: history)
try await attach(channel, to: fileSystem, path: "/home/")
await channel.openDirectory("/srv")
await channel.reload()
#expect(history.entries(for: hostID).map(\.path) == ["/srv", "/home"])
#expect(history.entries().map(\.path) == ["/srv", "/home"])
await channel.openDirectory("/missing")
#expect(history.entries(for: hostID).map(\.path) == ["/srv", "/home"])
#expect(history.entries().map(\.path) == ["/srv", "/home"])
}
@Test func staleDirectoryResponseDoesNotCreateHistory() async throws {
let hostID = UUID()
let history = SFTPHistoryModel()
let staleFileSystem = MobileDeferredFileSystem()
let currentFileSystem = MobileBrowserFileSystem(listings: ["/current": []])
let channel = MobileSFTPChannelModel(hostID: hostID, history: history)
let channel = MobileSFTPChannelModel(history: history)
let staleGeneration = UUID()
let staleAttach = Task {
@@ -213,7 +211,7 @@ struct MobileSFTPChannelTests {
await staleAttach.value
#expect(channel.path == "/current")
#expect(history.entries(for: hostID).map(\.path) == ["/current"])
#expect(history.entries().map(\.path) == ["/current"])
}
private func attach(
+18 -14
View File
@@ -6,29 +6,31 @@ import UIKit
@testable import TermindIOS
@Suite(.serialized)
@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"))
@MainActor
struct MobileSFTPHistoryTests {
@Test func deletingAHistoryEntryUpdatesThePresentedList() async throws {
@Test func appendingAHistoryEntryUpdatesThePresentedList() async throws {
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
let library = HostLibraryModel(hosts: [host])
let history = SFTPHistoryModel()
history.record(kind: .directory, path: "/srv", for: host.id)
history.record(kind: .file, path: "/srv/report.txt", for: host.id)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
sftpHistory: history
)
hosts: library)
let history = workspace.sftpHistory
history.record(kind: .directory, path: "/srv")
history.record(kind: .file, path: "/srv/report.txt")
let controller = UIHostingController(
rootView: MobileSFTPHistoryView(workspace: workspace, open: { _ in })
)
let window = UIWindow(frame: UIScreen.main.bounds)
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previousWindow = scene.keyWindow
let window = UIWindow(windowScene: scene)
window.windowLevel = .alert + 1
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
controller.view.frame = window.bounds
controller.view.setNeedsLayout()
@@ -36,11 +38,13 @@ struct MobileSFTPHistoryTests {
try await Task.sleep(for: .milliseconds(300))
#expect(visibleCells(in: controller.view).count == 2)
let newest = try #require(history.entries(for: host.id).first)
history.delete(newest.id)
try await Task.sleep(for: .milliseconds(300))
#expect(visibleCells(in: controller.view).count == 1)
history.record(kind: .directory, path: "/new")
for _ in 0..<100 where visibleCells(in: controller.view).count != 3 {
try await Task.sleep(for: .milliseconds(20))
controller.view.layoutIfNeeded()
}
#expect(history.entries().map(\.path) == ["/new", "/srv/report.txt", "/srv"])
#expect(visibleCells(in: controller.view).count == 3)
}
private func visibleCells(in root: UIView) -> [UIView] {
+8 -6
View File
@@ -159,23 +159,23 @@ struct MobileSettingsPortTests {
app.library.upsert(host)
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library, terminalSettings: app.terminalSettings, commandHistory: app.commandHistory)
hosts: app.library, terminalSettings: app.terminalSettings)
workspace.receiveShellEvent(.commandStarted("not-authenticated"))
#expect(app.commandHistory.entries(for: host.id).isEmpty)
#expect(workspace.commandHistory.entries().isEmpty)
workspace.receiveShellEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil))
workspace.receiveShellEvent(
.commandStarted("echo hello \\\n world\nprintf retained")
)
workspace.receiveShellEvent(.commandFinished(0, duration: 1.25))
workspace.receiveShellEvent(.commandFinished(1, duration: 9))
let entry = try #require(app.commandHistory.entries(for: host.id).first)
let entry = try #require(workspace.commandHistory.entries().first)
#expect(entry.command == "echo hello world\nprintf retained")
#expect(entry.exitStatus == 0)
#expect(entry.duration == 1.25)
workspace.receiveShellEvent(.commandStarted("interrupted"))
workspace.disconnect()
workspace.receiveShellEvent(.commandFinished(0, duration: 1))
#expect(app.commandHistory.entries(for: host.id).first?.exitStatus == nil)
#expect(workspace.commandHistory.entries().first?.exitStatus == nil)
}
@Test func findQuerySurvivesDismissalAndNavigationReopensSearch() throws {
@@ -201,10 +201,12 @@ struct MobileSettingsPortTests {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
app.commandHistory.record("test", for: host.id)
let id = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
let workspace = try #require(app.workspaces.first { $0.id == id })
workspace.commandHistory.record("test")
app.applyRemoteVault(.init(), keys: [VaultRecordKey(kind: .host, id: host.id)])
#expect(app.library.host(host.id) == nil)
#expect(app.commandHistory.entries(for: host.id).isEmpty)
#expect(workspace.commandHistory.entries().isEmpty)
}
}
+12 -27
View File
@@ -6,47 +6,32 @@ import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1")) @MainActor
struct MobileTerminalListTests {
@Test func deletingAnEndpointUpdatesTheNativeHistoryList() async throws {
@Test func appendingACommandUpdatesTheNativeHistoryList() async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
app.commandHistory.record("pwd", for: host.id)
app.commandHistory.record("whoami", for: host.id)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library,
commandHistory: app.commandHistory)
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
workspace.commandHistory.record("pwd")
try await withHostedView(MobileCommandHistoryView(library: app.library, workspace: workspace)) { root in
#expect(nativeVisibleCells(in: root.view).count == 2)
let newest = try #require(app.commandHistory.entries(for: host.id).first)
app.commandHistory.delete(newest.id)
try await Task.sleep(for: .milliseconds(300))
#expect(nativeVisibleCells(in: root.view).count == 1)
workspace.commandHistory.record("whoami")
try await Task.sleep(for: .milliseconds(300))
#expect(nativeVisibleCells(in: root.view).count == 2)
}
}
@Test func retainedHistoryKeepsOnlyTheNativeViewportMounted() async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
let history = CommandHistoryModel(
entries: (0..<CommandHistoryModel.perHostLimit).map { index in
CommandHistoryEntry(
hostID: host.id, command: "printf '%04d' \(index)",
executedAt: Date(timeIntervalSince1970: TimeInterval(index)))
})
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library,
commandHistory: history)
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
for index in 0..<CommandHistoryModel.perSessionLimit {
workspace.commandHistory.record("printf '%04d' \(index)")
}
try await withHostedView(MobileCommandHistoryView(library: app.library, workspace: workspace)) { root in
#expect(history.entries(for: host.id).count == CommandHistoryModel.perHostLimit)
#expect(workspace.commandHistory.entries().count == CommandHistoryModel.perSessionLimit)
let cells = nativeVisibleCells(in: root.view)
#expect(!cells.isEmpty)
#expect(cells.count < 50)
+3 -16
View File
@@ -61,8 +61,6 @@ final class AppModel {
let snippets: SnippetLibraryModel
let forwardings: ForwardingLibraryModel
let transfers: TransferLibraryModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
let connectionHistory: ConnectionHistoryModel
let devices: DeviceDirectoryModel
private let externalToolCalls: ToolAuditModel
@@ -185,8 +183,6 @@ final class AppModel {
sshSettings: SSHSettingsModel = SSHSettingsModel(),
sftpSettings: SFTPSettingsModel = SFTPSettingsModel(),
workspaceSettings: WorkspaceSettingsModel = WorkspaceSettingsModel(),
commandHistory: CommandHistoryModel = CommandHistoryModel(),
sftpHistory: SFTPHistoryModel = SFTPHistoryModel(),
connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(),
devices: DeviceDirectoryModel? = nil,
tailscaleStore: (any TailscaleLocalStoring)? = nil,
@@ -232,8 +228,6 @@ final class AppModel {
snippets = SnippetLibraryModel(library: hosts)
forwardings = ForwardingLibraryModel(library: hosts)
transfers = TransferLibraryModel(library: hosts)
self.commandHistory = commandHistory
self.sftpHistory = sftpHistory
self.connectionHistory = connectionHistory
let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
self.devices = devices
@@ -514,8 +508,6 @@ final class AppModel {
sftpTransfers.cancelAll(for: change.id)
externalAuthentication.cancel(hostID: change.id)
mcpServer.tools.revokeHost(change.id)
commandHistory.clear(for: change.id)
sftpHistory.clear(for: change.id)
connectionHistory.clear(for: change.id)
}
}
@@ -723,8 +715,6 @@ final class AppModel {
window.metricsModel = serverMetrics
windows = windows.filter { $0.value.value != nil }
windows[ObjectIdentifier(window)] = WeakWindow(window)
window.commandHistoryStore = commandHistory
window.sftpHistoryStore = sftpHistory
window.assistantLibrary.adopt(assistantConversations)
window.assistantLibrary.modelSelections = assistantModelSelections
window.assistantCommands = AssistantConversationCommands(
@@ -897,8 +887,6 @@ final class AppModel {
serverMetrics.remove(hostID)
forwarding.hostConfigurationChanged(hostID)
forwarding.remove(hosts.portForwardings(for: hostID).map(\.id))
commandHistory.clear(for: hostID)
sftpHistory.clear(for: hostID)
connectionHistory.clear(for: hostID)
hosts.projectHostDeletion(hostID)
return true
@@ -1137,9 +1125,10 @@ final class AppModel {
localized: AppStrings.SFTP.executableFileWarning)
return
}
channel.open(file, coordinator: self.sftpTransfers, edits: self.sftpEdits) { localURL in
channel.open(file, coordinator: self.sftpTransfers, edits: self.sftpEdits) {
[history = channel.history] localURL in
if NSWorkspace.shared.open(localURL) {
channel.recordOpenedFile(at: file.path)
history.record(kind: .file, path: file.path)
}
}
}
@@ -1174,8 +1163,6 @@ final class AppModel {
func clearAllHistory(for hostIDs: [Host.ID]) {
for hostID in Set(hostIDs) {
connectionHistory.clear(for: hostID)
commandHistory.clear(for: hostID)
sftpHistory.clear(for: hostID)
}
}
-4
View File
@@ -169,10 +169,6 @@ struct TermindApp: App {
sshSettings: SSHSettingsModel(persistence: settingsPersistence),
sftpSettings: SFTPSettingsModel(persistence: settingsPersistence),
workspaceSettings: WorkspaceSettingsModel(persistence: settingsPersistence),
commandHistory: CommandHistoryModel(modelContext: container.mainContext),
sftpHistory: SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: container.mainContext)
),
connectionHistory: ConnectionHistoryModel(
repository: connectionHistoryRepository,
sourceDeviceID: connectionHistoryDeviceID
+6 -4
View File
@@ -1018,20 +1018,22 @@ struct SFTPBrowserView: View {
if file.isDirectory {
sftpChannel.navigate(to: file.path)
} else {
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) { localURL in
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
[history = sftpChannel.history] localURL in
if NSWorkspace.shared.open(localURL) {
sftpChannel.recordOpenedFile(at: file.path)
history.record(kind: .file, path: file.path)
}
}
}
}
private func open(_ file: RemoteFile, with application: URL) {
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) { localURL in
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
[history = sftpChannel.history] localURL in
Task {
do {
try await SFTPApplicationOpener.open(localURL, with: application)
sftpChannel.recordOpenedFile(at: file.path)
history.record(kind: .file, path: file.path)
} catch { sftpChannel.operationError = error.localizedDescription }
}
}
+10 -7
View File
@@ -86,7 +86,8 @@ private struct SFTPDirectoryPresentation {
@Observable
final class SFTPChannelModel: Identifiable {
let id = UUID()
var history = SFTPHistoryModel()
var id: UUID { history.sessionID }
let hostID: Host.ID
let initialPath: String
var homeResolution: RemoteHomeResolutionState = .pending
@@ -169,7 +170,6 @@ final class SFTPChannelModel: Identifiable {
weak var navigationRouter: (any SFTPChannelNavigationRouting)?
private weak var statusStore: (any RemoteSessionHostStore)?
private weak var transferCoordinator: SFTPTransferCoordinator?
weak var historyStore: SFTPHistoryModel?
private var didResolveHomeForConnection = false
private let automaticRefreshInterval: Duration
var connectionCoordinator: RemoteConnectionCoordinator?
@@ -304,6 +304,7 @@ final class SFTPChannelModel: Identifiable {
}
func prepareForReopen() {
history = SFTPHistoryModel()
startsOnMount = true
}
@@ -847,6 +848,8 @@ final class SFTPChannelModel: Identifiable {
func reload(silently: Bool = false) async {
guard let client, status == .connected else { return }
let requestedPath = path
let requestedHistory = history
let requestedAttempt = connectionAttemptID
if !silently {
isLoading = true
hint = String(localized: AppStrings.SFTP.loading)
@@ -854,10 +857,12 @@ final class SFTPChannelModel: Identifiable {
defer { if !silently { isLoading = false } }
do {
let snapshot = try await client.listDirectory(at: requestedPath)
guard status == .connected, path == requestedPath else { return }
guard status == .connected, path == requestedPath, connectionAttemptID == requestedAttempt,
history === requestedHistory
else { return }
replaceDirectoryContents(with: snapshot)
if !silently {
historyStore?.record(kind: .directory, path: requestedPath, for: hostID)
history.record(kind: .directory, path: requestedPath)
}
hint = String(localized: AppStrings.SFTP.items(snapshot.count))
} catch let error as SFTPError {
@@ -1067,9 +1072,7 @@ final class SFTPChannelModel: Identifiable {
}
func recordOpenedFile(at path: String) {
if connectionOrigin.allowsPersistence {
historyStore?.record(kind: .file, path: path, for: hostID)
}
history.record(kind: .file, path: path)
}
func reopenHistoryFile(
+18 -148
View File
@@ -485,14 +485,12 @@ enum TerminalSidebarMenuBuilder {
}
static func history(
entry: CommandHistoryEntry?, canInsert: Bool, canClear: Bool, locale: Locale,
entry: CommandHistoryEntry?, canInsert: Bool, locale: Locale,
copy: @escaping (CommandHistoryEntry) -> Void,
insert: @escaping (CommandHistoryEntry) -> Void,
saveAsSnippet: @escaping (CommandHistoryEntry) -> Void,
remove: @escaping (CommandHistoryEntry) -> Void,
clear: @escaping () -> Void
saveAsSnippet: @escaping (CommandHistoryEntry) -> Void
) -> [NativeMenuEntry] {
var entries = [
let entries = [
NativeMenuEntry(
title: localized(AppStrings.Common.copy, locale: locale),
symbol: MenuSymbol.copy,
@@ -515,25 +513,6 @@ enum TerminalSidebarMenuBuilder {
if let entry { saveAsSnippet(entry) }
},
]
if let entry {
entries.append(
NativeMenuEntry(
title: localized(AppStrings.Common.delete, locale: locale),
symbol: MenuSymbol.delete,
imageVisibility: .visible
) {
remove(entry)
})
}
entries.append(.separator)
entries.append(
NativeMenuEntry(
title: localized(AppStrings.Common.clearAll, locale: locale),
symbol: MenuSymbol.clearHistory,
imageVisibility: .visible,
isEnabled: canClear,
action: clear
))
return entries
}
@@ -815,7 +794,6 @@ private struct SSHCommandHistorySidebar: View {
@State private var query = ""
@State private var destructiveEntry: CommandHistoryEntry?
@State private var confirmsClear = false
@State private var namingEntryID: CommandHistoryEntry.ID?
@State private var snippetName = ""
@State private var selectedID: CommandHistoryEntry.ID?
@@ -824,25 +802,10 @@ private struct SSHCommandHistorySidebar: View {
window.selectedWorkspace?.focusedPane.sshSession
}
private var isSavedHost: Bool {
guard let hostID = sshSession?.hostID else { return false }
return app.hostLibrary.host(hostID) != nil
}
private var allEntries: [CommandHistoryEntry] {
guard let sshSession else { return [] }
_ = app.commandHistory.revision
if isSavedHost {
return DesktopHistoryPresentation.limited(
app.commandHistory.entries(for: sshSession.hostID, matching: query)
)
}
let entries = sshSession.transientCommandHistory
let matches =
query.isEmpty
? entries
: entries.filter { $0.command.localizedCaseInsensitiveContains(query) }
return DesktopHistoryPresentation.limited(matches)
_ = sshSession.commandHistory.revision
return DesktopHistoryPresentation.limited(sshSession.commandHistory.entries(matching: query))
}
var body: some View {
@@ -895,16 +858,6 @@ private struct SSHCommandHistorySidebar: View {
SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory))
}
.confirmationDialog(
AppStrings.Terminal.clearCommandHistoryForThisHostConfirmation,
isPresented: $confirmsClear,
titleVisibility: .visible
) {
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive, action: clearHistory)
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
} message: {
Text(AppStrings.Terminal.clearCommandHistoryWarning)
}
.confirmationDialog(
AppStrings.Assistant.executeADestructiveCommandConfirmation,
isPresented: Binding(
@@ -922,9 +875,7 @@ private struct SSHCommandHistorySidebar: View {
}
}
/// The blank area offers the same menu a row does, with everything that
/// needs a row greyed out. Clear All is not one of those — it is about
/// the list, so it stays live where there is nothing under the pointer.
/// The blank area offers the row menu with entry-specific actions disabled.
private var globalMenu: [NativeMenuEntry] {
historyMenu(for: nil)
}
@@ -957,8 +908,7 @@ private struct SSHCommandHistorySidebar: View {
} else {
CommandHistoryCard(
entry: entry,
isSelected: selectedID == entry.id,
delete: { delete(entry) }
isSelected: selectedID == entry.id
)
.onTapGesture(count: 2) {
selectedID = entry.id
@@ -978,13 +928,10 @@ private struct SSHCommandHistorySidebar: View {
TerminalSidebarMenuBuilder.history(
entry: entry,
canInsert: canInsert,
canClear: !allEntries.isEmpty,
locale: locale,
copy: { copyToPasteboard($0.command) },
insert: insert,
saveAsSnippet: beginSnippetNaming,
remove: delete,
clear: { confirmsClear = true }
saveAsSnippet: beginSnippetNaming
)
}
@@ -1037,23 +984,6 @@ private struct SSHCommandHistorySidebar: View {
destructiveEntry = nil
}
private func delete(_ entry: CommandHistoryEntry) {
if isSavedHost {
app.commandHistory.delete(entry.id)
} else {
sshSession?.deleteTransientHistory(entry.id)
}
}
private func clearHistory() {
guard let sshSession else { return }
if isSavedHost {
app.commandHistory.clear(for: sshSession.hostID)
} else {
sshSession.clearTransientHistory()
}
}
private func beginSnippetNaming(_ entry: CommandHistoryEntry) {
snippetName = ""
namingEntryID = entry.id
@@ -1082,7 +1012,6 @@ private struct SFTPBrowseHistorySidebar: View {
@State private var query = ""
@State private var selectedID: SFTPHistoryEntry.ID?
@State private var confirmsClear = false
private var session: SFTPChannelModel? {
window.selectedWorkspace?.focusedPane.sftpChannel
@@ -1090,8 +1019,8 @@ private struct SFTPBrowseHistorySidebar: View {
private var entries: [SFTPHistoryEntry] {
guard let session else { return [] }
_ = app.sftpHistory.revision
let values = app.sftpHistory.entries(for: session.hostID)
_ = session.history.revision
let values = session.history.entries()
let matches =
query.isEmpty
? values
@@ -1135,37 +1064,9 @@ private struct SFTPBrowseHistorySidebar: View {
SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory))
}
.confirmationDialog(
AppStrings.SFTP.clearFileHistoryForThisHostConfirmation, isPresented: $confirmsClear,
titleVisibility: .visible
) {
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) {
if let session { app.sftpHistory.clear(for: session.hostID) }
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
}
}
private var globalMenu: [NativeMenuEntry] {
[
NativeMenuEntry(
title: AppStrings.Common.open, symbol: MenuSymbol.open, isEnabled: false
),
NativeMenuEntry(
title: AppStrings.Common.delete,
symbol: MenuSymbol.delete,
imageVisibility: .visible,
isEnabled: false
),
.separator,
NativeMenuEntry(
title: AppStrings.Common.clearAll,
symbol: MenuSymbol.clearHistory,
imageVisibility: .visible,
isEnabled: !entries.isEmpty,
) { confirmsClear = true },
]
}
private var globalMenu: [NativeMenuEntry] { [] }
private func row(_ entry: SFTPHistoryEntry, displayedPath: String) -> some View {
SidebarCard(
@@ -1183,18 +1084,7 @@ private struct SFTPBrowseHistorySidebar: View {
[
NativeMenuEntry(title: AppStrings.Common.open, symbol: MenuSymbol.open) {
reopen(entry)
},
NativeMenuEntry(
title: AppStrings.Common.delete,
symbol: MenuSymbol.delete,
imageVisibility: .visible,
) { app.sftpHistory.delete(entry.id) },
.separator,
NativeMenuEntry(
title: AppStrings.Common.clearAll,
symbol: MenuSymbol.clearHistory,
imageVisibility: .visible,
) { confirmsClear = true },
}
]
}
}
@@ -1206,8 +1096,9 @@ private struct SFTPBrowseHistorySidebar: View {
session.navigate(to: entry.path)
case .file:
session.reopenHistoryFile(at: entry.path, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
[history = session.history] in
if NSWorkspace.shared.open($0) {
session.recordOpenedFile(at: entry.path)
history.record(kind: .file, path: entry.path)
}
}
}
@@ -1400,45 +1291,24 @@ extension View {
private struct CommandHistoryCard: View {
let entry: CommandHistoryEntry
let isSelected: Bool
let delete: () -> Void
@Environment(\.locale) private var locale
@State private var isHovering = false
var body: some View {
let subtitle = CommandHistoryPresentation.subtitle(for: entry, locale: locale)
SidebarCard(
symbol: nil,
title: CommandHistoryPresentation.title(for: entry),
titleIsMonospaced: true,
titleLineLimit: 1,
subtitle: CommandHistoryPresentation.subtitle(for: entry, locale: locale),
subtitle: subtitle.isEmpty ? nil : subtitle,
subtitleIsMonospaced: false,
isSelected: isSelected
) {
ZStack {
if isHovering {
Button(action: delete) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.frame(width: 18, height: 18)
.contentShape(.rect)
}
.buttonStyle(.plain)
.help(AppStrings.Common.delete.swiftUIKey)
.accessibilityLabel(AppStrings.Common.delete.swiftUIKey)
} else {
RiskBadge(
assessment: CommandRiskClassifier.assess(entry.command),
compact: true
)
}
}
.frame(width: 18, height: 18, alignment: .center)
RiskBadge(assessment: CommandRiskClassifier.assess(entry.command), compact: true)
.frame(width: 18, height: 18, alignment: .center)
}
.onHover { isHovering = $0 }
.help(Text(entry.command))
}
}
/// A native borderless field whose editor remains transparent inside a Sidebar
+10 -38
View File
@@ -30,7 +30,8 @@ protocol SSHSessionInputRouting: AnyObject {
/// terminal backend to one SSH session and exposes connection state to SwiftUI.
@Observable
final class SSHSessionModel: Identifiable {
let id = UUID()
let commandHistory = CommandHistoryModel()
var id: UUID { commandHistory.sessionID }
/// Never optional. Termind ships no local terminal — the App Sandbox has no
/// route to a login shell worth having, and shipping a crippled one would be
/// worse than shipping none — so a sshSession is always a sshSession *on* a host.
@@ -66,10 +67,6 @@ final class SSHSessionModel: Identifiable {
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.
private(set) var transientCommandHistory: [CommandHistoryEntry] = []
/// Find stays with this terminal's scrollback rather than with the transient
/// SwiftUI pane mounting it, so moving a terminal between panes carries its
/// open search and retained query along with the buffer they search. Closing
@@ -138,14 +135,16 @@ final class SSHSessionModel: Identifiable {
/// rather than the transient SwiftUI branch that currently displays it.
var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface }
private weak var statusStore: (any RemoteSessionHostStore)?
weak var commandHistoryStore: CommandHistoryModel?
private var ssh: SSHPTYChannel?
var connectionCoordinator: RemoteConnectionCoordinator?
private var connectionTask: Task<Void, Never>?
private var startsOnMount = true
private(set) var connectionAttemptID: UUID? {
didSet {
if connectionAttemptID != oldValue { terminalUpload.cancelPendingUpload() }
if connectionAttemptID != oldValue {
terminalUpload.cancelPendingUpload()
commandHistory.cancelLoading()
}
}
}
@ObservationIgnored lazy var terminalUpload = TerminalUploadModel(session: self)
@@ -774,6 +773,7 @@ final class SSHSessionModel: Identifiable {
}
func disconnect() {
commandHistory.cancelLoading()
terminalUpload.cancelPendingUpload()
commandMonitor.endConnection()
startsOnMount = false
@@ -886,6 +886,7 @@ final class SSHSessionModel: Identifiable {
temporaryHostKeys.removeAll()
_ = connection.markConnected()
status = .connected
commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil)
if presentsConnectionJourney {
connectionJourney.succeed()
} else {
@@ -1084,7 +1085,6 @@ final class SSHSessionModel: Identifiable {
copy.connectionRoute = connectionRoute
copy.connectionOrigin = connectionOrigin
copy.connectionCoordinator = connectionCoordinator
copy.commandHistoryStore = commandHistoryStore
return copy
}
@@ -1233,18 +1233,7 @@ final class SSHSessionModel: Identifiable {
commandExcludedFromHistory = nil
return nil
}
if statusStore?.host(hostID) != nil {
return commandHistoryStore?.record(command, for: hostID)?.id
} else {
let entry = CommandHistoryEntry(hostID: hostID, command: command)
transientCommandHistory.insert(entry, at: 0)
if transientCommandHistory.count > CommandHistoryModel.perHostLimit {
transientCommandHistory.removeLast(
transientCommandHistory.count - CommandHistoryModel.perHostLimit
)
}
return entry.id
}
return commandHistory.record(command)?.id
}
private func recordCommandCompletion(
@@ -1253,26 +1242,9 @@ final class SSHSessionModel: Identifiable {
guard let active = activeShellCommand else { return }
activeShellCommand = nil
guard let historyID = active.historyID else { return }
if statusStore?.host(hostID) != nil {
commandHistoryStore?.recordCompletion(
for: historyID, hostID: hostID,
exitStatus: exitStatus, duration: reportedDuration
)
} else if let index = transientCommandHistory.firstIndex(where: {
$0.id == historyID
}) {
transientCommandHistory[index].exitStatus = exitStatus
transientCommandHistory[index].duration = reportedDuration
}
commandHistory.recordCompletion(for: historyID, exitStatus: exitStatus, duration: reportedDuration)
}
func deleteTransientHistory(_ id: CommandHistoryEntry.ID) {
transientCommandHistory.removeAll { $0.id == id }
}
func clearTransientHistory() {
transientCommandHistory.removeAll()
}
}
private final class SSHSessionRelay: @unchecked Sendable {
+6 -12
View File
@@ -125,16 +125,6 @@ final class Pane: Identifiable {
}
}
func configureCommandHistory(using store: CommandHistoryModel) {
guard case .sshSession(let session) = content else { return }
session.commandHistoryStore = store
}
func configureSFTPHistory(using store: SFTPHistoryModel) {
guard case .sftpChannel(let session) = content else { return }
session.historyStore = store
}
func configureConnectionHistory(
entryID: ConnectionHistoryEntry.ID,
end: @escaping (ConnectionHistoryEntry.ID) -> Void
@@ -207,9 +197,13 @@ final class Pane: Identifiable {
endConnectionHistory = nil
}
switch content {
case .sshSession(let sshSession): sshSession.disconnect()
case .sshSession(let sshSession):
sshSession.commandHistory.close()
sshSession.disconnect()
case .telnetSession(let telnetSession): telnetSession.disconnect()
case .sftpChannel(let sftpChannel): sftpChannel.disconnect()
case .sftpChannel(let sftpChannel):
sftpChannel.history.close()
sftpChannel.disconnect()
case .serialSession(let serialSession): serialSession.disconnect()
default: break
}
-19
View File
@@ -113,18 +113,6 @@ final class WindowModel {
}
}
}
weak var commandHistoryStore: CommandHistoryModel? {
didSet {
guard let commandHistoryStore else { return }
for workspace in workspaces { workspace.configureCommandHistory(using: commandHistoryStore) }
}
}
weak var sftpHistoryStore: SFTPHistoryModel? {
didSet {
guard let sftpHistoryStore else { return }
for workspace in workspaces { workspace.configureSFTPHistory(using: sftpHistoryStore) }
}
}
var assistantCommands: AssistantConversationCommands?
var assistantOperationError: String?
var workspaceTemplateNotice: String?
@@ -865,12 +853,6 @@ final class WindowModel {
if let connectionCoordinator {
workspace.configureConnections(using: connectionCoordinator)
}
if let commandHistoryStore {
workspace.configureCommandHistory(using: commandHistoryStore)
}
if let sftpHistoryStore {
workspace.configureSFTPHistory(using: sftpHistoryStore)
}
workspaces.append(workspace)
if shouldSelect { select(workspace.id) }
return workspace
@@ -1024,7 +1006,6 @@ final class WindowModel {
let pane = Pane.sftpChannel(host: host, initialPath: initialPath)
configureConnectionHistory(pane)
if let connectionCoordinator { pane.configureConnections(using: connectionCoordinator) }
if let sftpHistoryStore { pane.configureSFTPHistory(using: sftpHistoryStore) }
if placement == .currentWorkspace,
let workspace = selectedWorkspace,
-8
View File
@@ -455,14 +455,6 @@ extension Workspace {
for pane in panes { pane.configureConnections(using: coordinator) }
}
func configureCommandHistory(using store: CommandHistoryModel) {
for pane in panes { pane.configureCommandHistory(using: store) }
}
func configureSFTPHistory(using store: SFTPHistoryModel) {
for pane in panes { pane.configureSFTPHistory(using: store) }
}
/// A host is required: there is no local terminal to open without one.
static func sshSession(host: Host, usernameHint: String? = nil) -> Workspace {
Workspace(pane: .sshSession(host: host, usernameHint: usernameHint))
@@ -1,9 +0,0 @@
import SwiftData
import TermindApplication
import TermindPersistence
extension CommandHistoryModel {
convenience init(modelContext: ModelContext) {
self.init(repository: SwiftDataCommandHistoryRepository(context: modelContext))
}
}
+62 -114
View File
@@ -19,18 +19,18 @@ struct CommandHistoryTests {
func historyNormalizesExplicitContinuationsAndPreservesRealLineBreaks() throws {
let hostID = UUID()
let legacy = CommandHistoryEntry(
hostID: hostID,
sessionID: hostID,
command: "printf legacy \\\r\n\t value\nprintf retained"
)
let store = CommandHistoryModel(entries: [legacy])
let store = CommandHistoryModel(sessionID: hostID, entries: [legacy])
let newCommand =
"printf new \\\n value \\\n again\nprintf retained"
let recorded = try #require(store.record(newCommand, for: hostID))
let recorded = try #require(store.record(newCommand))
#expect(recorded.command == "printf new value again\nprintf retained")
#expect(
store.entries(for: hostID).map(\.command) == [
store.entries().map(\.command) == [
"printf new value again\nprintf retained",
"printf legacy value\nprintf retained",
]
@@ -44,12 +44,12 @@ struct CommandHistoryTests {
@Test
func historyTitleShowsOneLogicalLineWithoutChangingTheCommand() {
let single = CommandHistoryEntry(hostID: UUID(), command: "uptime")
let single = CommandHistoryEntry(sessionID: UUID(), command: "uptime")
let multiline = CommandHistoryEntry(
hostID: UUID(), command: "printf first\nprintf second"
sessionID: UUID(), command: "printf first\nprintf second"
)
let windowsMultiline = CommandHistoryEntry(
hostID: UUID(), command: "printf first\r\nprintf second"
sessionID: UUID(), command: "printf first\r\nprintf second"
)
#expect(CommandHistoryPresentation.title(for: single) == "uptime")
@@ -58,118 +58,31 @@ struct CommandHistoryTests {
#expect(multiline.command == "printf first\nprintf second")
}
@Test
func memoryStoreKeepsDuplicatesSearchesAndScopesByHost() throws {
let firstHost = UUID()
let secondHost = UUID()
let store = CommandHistoryModel()
store.record("uptime", for: firstHost, at: Date(timeIntervalSince1970: 1))
let duplicate = try #require(
store.record("uptime", for: firstHost, at: Date(timeIntervalSince1970: 2))
)
store.record("系统状态", for: firstHost, at: Date(timeIntervalSince1970: 3))
store.record("other", for: secondHost, at: Date(timeIntervalSince1970: 4))
#expect(store.entries(for: firstHost).map(\.command) == ["系统状态", "uptime", "uptime"])
#expect(store.entries(for: firstHost, matching: "TIME").count == 2)
#expect(store.entries(for: secondHost).map(\.command) == ["other"])
store.delete(duplicate.id)
#expect(store.entries(for: firstHost).map(\.command) == ["系统状态", "uptime"])
store.clear(for: firstHost)
#expect(store.entries(for: firstHost).isEmpty)
#expect(store.entries(for: secondHost).count == 1)
@Test func memoryStoresKeepDuplicatesAndIsolateSessions() {
let first = CommandHistoryModel()
let second = CommandHistoryModel()
first.record("uptime")
first.record("uptime")
second.record("other")
#expect(first.entries(matching: "TIME").map(\.command) == ["uptime", "uptime"])
#expect(second.entries().map(\.command) == ["other"])
first.close()
#expect(first.entries().isEmpty)
#expect(second.entries().count == 1)
}
@Test
func completionEnrichesExistingHistoryWithoutCreatingAnotherRow() throws {
let hostID = UUID()
let store = CommandHistoryModel()
let entry = try #require(store.record("false", for: hostID))
let entry = try #require(store.record("false"))
let completed = store.recordCompletion(
for: entry.id, hostID: hostID, exitStatus: 1, duration: 0.25
for: entry.id, exitStatus: 1, duration: 0.25
)
#expect(completed?.exitStatus == 1)
#expect(completed?.duration == 0.25)
#expect(store.entries(for: hostID).count == 1)
}
@Test
func swiftDataStorePersistsIncrementally() throws {
let container = try makeContainer("CommandHistoryPersistence")
let hostID = UUID()
let entryID: UUID
do {
let store = CommandHistoryModel(modelContext: container.mainContext)
entryID = try #require(store.record("echo 持久化", for: hostID)).id
store.recordCompletion(
for: entryID, hostID: hostID, exitStatus: 7, duration: 1.5
)
#expect(store.storageError == nil)
#expect(
try container.mainContext.fetch(
FetchDescriptor<PersistentCommandHistoryEntry>()
).count == 1
)
}
let reloaded = CommandHistoryModel(modelContext: container.mainContext)
#expect(reloaded.entries(for: hostID).map(\.id) == [entryID])
#expect(reloaded.entries(for: hostID).map(\.command) == ["echo 持久化"])
#expect(reloaded.entries(for: hostID).map(\.exitStatus) == [7])
#expect(reloaded.entries(for: hostID).map(\.duration) == [1.5])
#expect(reloaded.storageError == nil)
}
@Test
func schemaV9HistoryMigratesWithUnknownCompletion() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("TermindCommandHistoryV10Migration-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: directory) }
let storeURL = directory.appendingPathComponent("Termind.store")
let legacySchema = Schema(versionedSchema: TermindSchemaV9.self)
let value = CommandHistoryEntry(
hostID: UUID(), command: "uptime",
executedAt: Date(timeIntervalSince1970: 123)
)
var legacyContainer: ModelContainer? = try ModelContainer(
for: legacySchema,
configurations: [
localModelConfiguration(
"CommandHistoryMigration", schema: legacySchema, url: storeURL
)
]
)
legacyContainer?.mainContext.insert(
TermindSchemaV9Models.PersistentCommandHistoryEntry(value)
)
try legacyContainer?.mainContext.save()
legacyContainer = nil
let migrated = try ModelContainer(
for: HostPersistence.schema,
migrationPlan: TermindMigrationPlan.self,
configurations: [
localModelConfiguration(
"CommandHistoryMigration", schema: HostPersistence.schema,
url: storeURL
)
]
)
let entries = try migrated.mainContext.fetch(
FetchDescriptor<PersistentCommandHistoryEntry>()
).map(\.value)
#expect(entries.map(\.command) == ["uptime"])
#expect(entries.first?.exitStatus == nil)
#expect(entries.first?.duration == nil)
#expect(store.entries().count == 1)
}
@Test
@@ -187,10 +100,10 @@ struct CommandHistoryTests {
session.recordExecutedCommand(command)
#expect(String(decoding: submitted, as: UTF8.self) == "\(command)\r")
#expect(session.transientCommandHistory.isEmpty)
#expect(session.commandHistory.entries().isEmpty)
session.recordExecutedCommand(command)
#expect(session.transientCommandHistory.map(\.command) == [command])
#expect(session.commandHistory.entries().map(\.command) == [command])
}
@Test
@@ -206,7 +119,7 @@ struct CommandHistoryTests {
session.submitPendingInitialCommand()
session.recordExecutedCommand(reportedCommand)
#expect(session.transientCommandHistory.isEmpty)
#expect(session.commandHistory.entries().isEmpty)
}
@Test
@@ -219,7 +132,7 @@ struct CommandHistoryTests {
session.submitPendingInitialCommand()
session.recordExecutedCommand("uptime")
#expect(session.transientCommandHistory.map(\.command) == ["uptime"])
#expect(session.commandHistory.entries().map(\.command) == ["uptime"])
}
@Test
@@ -247,7 +160,7 @@ struct CommandHistoryTests {
session.receiveShellIntegrationEvent(.commandStarted("false"))
session.receiveShellIntegrationEvent(.commandFinished(1, duration: 5))
let entry = try #require(session.transientCommandHistory.first)
let entry = try #require(session.commandHistory.entries().first)
#expect(entry.command == "false")
#expect(entry.exitStatus == 1)
#expect(entry.duration == 5)
@@ -261,10 +174,45 @@ struct CommandHistoryTests {
.commandStarted("printf first \\\n second\nprintf retained")
)
let entry = try #require(session.transientCommandHistory.first)
let entry = try #require(session.commandHistory.entries().first)
#expect(entry.command == "printf first second\nprintf retained")
}
@Test func sessionsOnTheSameHostAreIndependentAndClosingPreventsLateCommands() {
let host = Host(name: "Shared", host: "example.invalid")
let first = SSHSessionModel(host: host)
let second = SSHSessionModel(host: host)
first.recordExecutedCommand("first")
second.recordExecutedCommand("second")
first.disconnect()
#expect(first.commandHistory.entries().map(\.command) == ["first"])
let clone = first.copyForReconnect()
#expect(clone.commandHistory.entries().isEmpty)
let pane = Pane(.sshSession(first))
pane.disconnectConnection()
first.recordExecutedCommand("late")
#expect(first.commandHistory.entries().isEmpty)
#expect(second.commandHistory.entries().map(\.command) == ["second"])
pane.prepareForReopen()
#expect(pane.sshSession?.commandHistory.entries().isEmpty == true)
#expect(pane.sshSession?.id != first.id)
}
@Test func reopenedSFTPPaneCannotReceiveOldTransferHistory() {
let channel = SFTPChannelModel(hostID: UUID())
let pane = Pane(.sftpChannel(channel))
let oldHistory = channel.history
oldHistory.record(kind: .file, path: "/before")
pane.disconnectConnection()
pane.prepareForReopen()
oldHistory.record(kind: .file, path: "/late")
#expect(oldHistory.entries().isEmpty)
#expect(channel.history.entries().isEmpty)
#expect(channel.history.sessionID != oldHistory.sessionID)
channel.recordOpenedFile(at: "/current")
#expect(channel.history.entries().map(\.path) == ["/current"])
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = localModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
+8 -11
View File
@@ -19,8 +19,8 @@ struct ConnectionHistoryTests {
@Test
func sharedHistoryModelsUseTheSameRetentionLimit() {
#expect(ConnectionHistoryModel.limit == HistoryLimits.retainedEntries)
#expect(CommandHistoryModel.perHostLimit == HistoryLimits.retainedEntries)
#expect(SFTPHistoryModel.perHostLimit == HistoryLimits.retainedEntries)
#expect(CommandHistoryModel.perSessionLimit == HistoryLimits.retainedEntries)
#expect(SFTPHistoryModel.perSessionLimit == HistoryLimits.retainedEntries)
}
@Test
@@ -287,15 +287,12 @@ struct ConnectionHistoryTests {
ConnectionHistoryEntry(hostID: other.id, kind: .ssh, sourceDeviceID: localID),
],
sourceDeviceID: localID)
let commands = CommandHistoryModel(entries: [
CommandHistoryEntry(hostID: host.id, command: "uptime")
])
let files = SFTPHistoryModel(entries: [
SFTPHistoryEntry(hostID: host.id, kind: .directory, path: "/srv")
])
let session = SSHSessionModel(host: host)
session.commandHistory.record("uptime")
let files = SFTPHistoryModel()
files.record(kind: .directory, path: "/srv")
let app = AppModel(
hosts: HostLibraryModel(hosts: [host, other]),
commandHistory: commands, sftpHistory: files,
connectionHistory: connections
)
@@ -303,8 +300,8 @@ struct ConnectionHistoryTests {
#expect(app.hostLibrary.host(host.id) != nil)
#expect(connections.entries(for: host.id).isEmpty)
#expect(commands.entries(for: host.id).isEmpty)
#expect(files.entries(for: host.id).isEmpty)
#expect(session.commandHistory.entries().map(\.command) == ["uptime"])
#expect(files.entries().map(\.path) == ["/srv"])
#expect(connections.entries(for: other.id).count == 1)
#expect(connections.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [other.id])
}
@@ -18,8 +18,7 @@ struct HostShellIntegrationSessionTests {
var host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: mode)
let library = HostLibraryModel(hosts: [host])
let session = SSHSessionModel(hostID: host.id, title: host.name)
let history = CommandHistoryModel()
session.commandHistoryStore = history
let history = session.commandHistory
defer { session.disconnect() }
// The missing identity holds connection setup at authentication without opening a socket.
session.connect(using: library)
@@ -41,7 +40,7 @@ struct HostShellIntegrationSessionTests {
#expect(session.canGenerateFromCurrentDraft == expected)
session.receiveShellIntegrationEvent(.commandStarted("pwd"))
session.receiveShellIntegrationEvent(.commandFinished(0, duration: 0.1))
#expect(history.entries(for: host.id).count == (expected ? 1 : 0))
#expect(history.entries().count == (expected ? 1 : 0))
library.replacePersistedSnapshot(VaultSnapshot(hosts: [host]))
#expect(session.isShellIntegrationEnabled == expected)
+12 -15
View File
@@ -133,7 +133,7 @@ struct ListViewMigrationTests {
@Test
func sidebarHistoryKeepsIdentityWhileInlineEditingChanges() {
let entry = CommandHistoryEntry(hostID: UUID(), command: "pwd")
let entry = CommandHistoryEntry(sessionID: UUID(), command: "pwd")
let normal = SidebarListItem.history(entry, isNaming: false)
let naming = SidebarListItem.history(entry, isNaming: true)
@@ -152,9 +152,9 @@ struct ListViewMigrationTests {
let hostID = UUID()
let connection = ConnectionHistoryEntry(hostID: hostID, kind: .ssh)
let items: [SidebarListItem] = [
.history(CommandHistoryEntry(hostID: hostID, command: "pwd"), isNaming: false),
.history(CommandHistoryEntry(sessionID: hostID, command: "pwd"), isNaming: false),
.sftpHistory(
SFTPHistoryEntry(hostID: hostID, kind: .directory, path: "/var/log")
SFTPHistoryEntry(sessionID: hostID, kind: .directory, path: "/var/log")
),
.connection(
connection,
@@ -307,38 +307,35 @@ struct ListViewMigrationTests {
#expect(disabledSnippetEntries.map(\.title) == ["Copy", "Insert"])
#expect(disabledSnippetEntries.allSatisfy { !$0.isEnabled })
let history = CommandHistoryEntry(hostID: UUID(), command: "whoami")
let history = CommandHistoryEntry(sessionID: UUID(), command: "whoami")
let historyEntries = TerminalSidebarMenuBuilder.history(
entry: history, canInsert: true, canClear: true, locale: locale,
copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in },
remove: { _ in }, clear: {}
entry: history, canInsert: true, locale: locale,
copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in }
)
#expect(
historyEntries.map(\.title)
== [
"Copy", "Insert", "Save as Snippet", "Delete", "", "Clear All",
"Copy", "Insert", "Save as Snippet",
]
)
#expect(historyEntries[4].isSeparator)
#expect(!historyEntries.contains { $0.isSeparator })
let blankHistoryEntries = TerminalSidebarMenuBuilder.history(
entry: nil, canInsert: false, canClear: true, locale: locale,
copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in },
remove: { _ in }, clear: {}
entry: nil, canInsert: false, locale: locale,
copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in }
)
#expect(
blankHistoryEntries.map(\.title)
== ["Copy", "Insert", "Save as Snippet", "", "Clear All"]
== ["Copy", "Insert", "Save as Snippet"]
)
#expect(blankHistoryEntries.prefix(3).allSatisfy { !$0.isEnabled })
#expect(blankHistoryEntries.last?.isEnabled == true)
}
}
@Test
func sftpHistoryDisplayPathParticipatesInRowContentUpdates() {
let entry = SFTPHistoryEntry(
hostID: UUID(), kind: .directory, path: "/home/deploy/project"
sessionID: UUID(), kind: .directory, path: "/home/deploy/project"
)
let absolute = SidebarListItem.sftpHistory(entry)
-11
View File
@@ -192,15 +192,6 @@ struct RepositoryOutboxTests {
}
context.insert(PersistentSnippet(snippet))
context.insert(PersistentPortForwardingRule(rule))
context.insert(
PersistentCommandHistoryEntry(
CommandHistoryEntry(hostID: doomed.id, command: "uptime")
)
)
context.insert(
PersistentSFTPHistoryEntry(
SFTPHistoryEntry(hostID: doomed.id, kind: .directory, path: "/srv")
))
let connectionHistory = ConnectionHistoryEntry(hostID: doomed.id, kind: .ssh)
context.insert(PersistentConnectionHistoryEntry(connectionHistory))
try context.save()
@@ -236,8 +227,6 @@ struct RepositoryOutboxTests {
== [dependent.id]
)
#expect(try context.fetch(FetchDescriptor<PersistentPortForwardingRule>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentCommandHistoryEntry>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).isEmpty)
let connectionHistoryEnvelope = try #require(
try HistorySyncJournal(context: context).envelope(