From 8a9793eadb0200c34aef83ded1f1d51fe18709bd Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 21 Sep 2026 12:50:10 +0800 Subject: [PATCH] Add Host startup snippets --- .../HostShellIntegrationTests.swift | 14 ++- .../PersistenceCompatibilityTests.swift | 33 +++++++ Frameworks/Resources/Localizable.xcstrings | 16 ++++ .../Connections/RemoteSessionHostStore.swift | 2 + .../FeatureModels/HostLibraryModel.swift | 14 ++- Frameworks/TermindDomain/Hosts/Host.swift | 8 +- .../TermindPersistence/HostPersistence.swift | 13 ++- .../TermindPersistence.swift | 2 +- .../TermindSchemaV26HostModels.swift | 89 +++++++++++++++++++ .../HostIntegrationSection.swift | 34 ++++++- Memory/ARCHITECTURE.md | 3 + .../HostEditor/MobileHostEditor.swift | 15 +++- TFI/Features/Workspaces/MobileWorkspace.swift | 35 ++++++-- ...MobileCommandGeneratorAndUploadTests.swift | 66 ++++++++++++++ TFM/Features/HostEditor/HostEditorView.swift | 15 +++- TFM/Features/Terminal/SSHSessionModel.swift | 33 +++++-- .../HostShellIntegrationSessionTests.swift | 34 +++++++ TFMTests/SnippetFeatureTests.swift | 32 +++++++ 18 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 Frameworks/TermindPersistence/TermindSchemaV26HostModels.swift diff --git a/FrameworkTests/HostShellIntegrationTests.swift b/FrameworkTests/HostShellIntegrationTests.swift index 73e9e564..31812aa7 100644 --- a/FrameworkTests/HostShellIntegrationTests.swift +++ b/FrameworkTests/HostShellIntegrationTests.swift @@ -13,7 +13,10 @@ struct HostShellIntegrationTests { func overridesResolveAndRoundTrip(mode: HostShellIntegrationMode, global: Bool) throws { let expected = mode == .enabled || (mode == .followGlobalSettings && global) #expect(mode.isEnabled(globalSetting: global) == expected) - let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: mode) + let host = Host( + name: "Server", host: "server.invalid", shellIntegrationMode: mode, + startupSnippetID: UUID() + ) #expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host) #expect(PersistentHost(host).value == host) } @@ -26,6 +29,11 @@ struct HostShellIntegrationTests { let decoded = try JSONDecoder().decode(Host.self, from: JSONSerialization.data(withJSONObject: json)) #expect(decoded == host) } + json.removeValue(forKey: "startupSnippetID") + let decoded = try JSONDecoder().decode( + Host.self, from: JSONSerialization.data(withJSONObject: json) + ) + #expect(decoded.startupSnippetID == nil) } @Test func savedModesSyncAndCanReturnToFollowingGlobalSettings() throws { @@ -35,7 +43,9 @@ struct HostShellIntegrationTests { let destination = HostPersistence(context: destinationContainer.mainContext) let library = HostLibraryModel(persistence: source) let journal = VaultSyncJournal(context: sourceContainer.mainContext) - var host = Host(name: "Server", host: "server.invalid") + var host = Host( + name: "Server", host: "server.invalid", startupSnippetID: UUID() + ) library.upsert(host) host = try #require(library.host(host.id)) let key = VaultRecordKey(kind: .host, id: host.id) diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index ef56721d..135f8bf7 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -69,13 +69,46 @@ struct PersistenceCompatibilityTests { #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV23.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV24.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV25.versionIdentifier }) + #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV26.versionIdentifier }) #expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self }) #expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) + #expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) + #expect(!TermindSchemaV26.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) + #expect(TermindSchemaV26.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self }) #expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" }) #expect(first.schema.entities.contains { $0.name == "PersistentSSHPreferences" }) #expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" }) } + @Test + func version25StoreMigratesWithNoStartupSnippet() 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("Termind.store") + let oldSchema = Schema(versionedSchema: TermindSchemaV25.self) + let oldConfiguration = ModelConfiguration( + "Termind", schema: oldSchema, url: url, cloudKitDatabase: .none + ) + var oldContainer: ModelContainer? = try ModelContainer( + for: oldSchema, configurations: [oldConfiguration] + ) + oldContainer?.mainContext.insert( + TermindSchemaV24HostModels.PersistentHost( + Host(name: "Before startup snippets", host: "server.invalid") + ) + ) + try oldContainer?.mainContext.save() + oldContainer = nil + + let migrated = try TermindPersistence.makeContainer(url: url) + let host = try #require( + migrated.mainContext.fetch(FetchDescriptor()).first?.value + ) + #expect(host.name == "Before startup snippets") + #expect(host.startupSnippetID == nil) + } + @Test func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7eee1871..e0c5cea4 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -23983,6 +23983,22 @@ } } }, + "Startup Snippet" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动片段" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟動片段" + } + } + } + }, "Status" : { "localizations" : { "zh-Hans" : { diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index b2d1e0d3..c81ca4bc 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -9,6 +9,7 @@ import struct TermindDomain.Host @MainActor public protocol RemoteSessionHostStore: AnyObject, Sendable { func host(_ id: Host.ID) -> Host? + func snippet(_ id: Snippet.ID) -> Snippet? func identity(_ id: Identity.ID) -> Identity? func identity(for host: Host) -> Identity? func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? @@ -68,6 +69,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { } extension RemoteSessionHostStore { + public func snippet(_ id: Snippet.ID) -> Snippet? { nil } public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { nil } public var customPasswordStorage: PasswordStorage { .iCloud } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 47cb2de2..40c50f1e 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -1699,6 +1699,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func delete(snippetIDs: [Snippet.ID]) { let doomed = Set(snippetIDs) + clearStartupSnippetReferences(to: doomed) snippets.removeAll { doomed.contains($0.id) } persistCore() } @@ -1734,13 +1735,24 @@ public final class HostLibraryModel: RemoteSessionHostStore { snippets[index].sortIndex = nextSnippetSortIndex(in: nil) } } else { - snippets.removeAll { $0.packageID.map(doomed.contains) == true } + let doomedSnippetIDs = Set( + snippets.lazy.filter { $0.packageID.map(doomed.contains) == true }.map(\.id) + ) + clearStartupSnippetReferences(to: doomedSnippetIDs) + snippets.removeAll { doomedSnippetIDs.contains($0.id) } } snippetPackages.removeAll { doomed.contains($0.id) } reconcileExpansionState() persistCore() } + private func clearStartupSnippetReferences(to snippetIDs: Set) { + guard !snippetIDs.isEmpty else { return } + for index in hosts.indices where hosts[index].startupSnippetID.map(snippetIDs.contains) == true { + hosts[index].startupSnippetID = nil + } + } + public func move( snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?, before next: Snippet.ID? = nil diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index e2031366..b7ab54e8 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -90,6 +90,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public var tags: [String] = [] public var colorTag: TagColor = .none public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings + /// A reusable script submitted after each interactive SSH shell becomes ready. + /// The body is resolved from the Snippet library for every connection attempt. + public var startupSnippetID: Snippet.ID? /// Physical SSH connections available to local and dynamic forwarding on supported platforms. public var portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections /// Last distribution reported by Shell Integration for this saved Host. @@ -145,6 +148,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { groupID: HostGroup.ID? = nil, tags: [String] = [], colorTag: TagColor = .none, detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0, shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings, + startupSnippetID: Snippet.ID? = nil, portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections ) { self.id = id @@ -161,6 +165,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { self.detectedDistribution = detectedDistribution self.sortIndex = sortIndex self.shellIntegrationMode = shellIntegrationMode + self.startupSnippetID = startupSnippetID self.portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections( portForwardingMaxConnections) } @@ -169,7 +174,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { case id, name, host, port, identityID, jumpHostID, proxy case sftpChannelInitialPath = "sftpSessionInitialPath" case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode - case portForwardingMaxConnections + case startupSnippetID, portForwardingMaxConnections } public init(from decoder: any Decoder) throws { @@ -190,6 +195,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { shellIntegrationMode = try values.decodeIfPresent(String.self, forKey: .shellIntegrationMode) .flatMap(HostShellIntegrationMode.init(rawValue:)) ?? .followGlobalSettings + startupSnippetID = try values.decodeIfPresent(Snippet.ID.self, forKey: .startupSnippetID) portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections( try values.decodeIfPresent(Int.self, forKey: .portForwardingMaxConnections) ?? Self.defaultPortForwardingMaxConnections) diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index d122980d..9f3699c6 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -746,6 +746,15 @@ public enum TermindSchemaV25: VersionedSchema { } } +public enum TermindSchemaV26: VersionedSchema { + public static let versionIdentifier = Schema.Version(26, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV25.models.filter { + $0 != TermindSchemaV24HostModels.PersistentHost.self + } + [TermindSchemaV26HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -756,6 +765,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self, TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self, TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self, + TermindSchemaV26.self, ] } @@ -822,10 +832,11 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV22.self, toVersion: TermindSchemaV23.self), MigrationStage.lightweight(fromVersion: TermindSchemaV23.self, toVersion: TermindSchemaV24.self), MigrationStage.lightweight(fromVersion: TermindSchemaV24.self, toVersion: TermindSchemaV25.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV25.self, toVersion: TermindSchemaV26.self), ] } } -public typealias PersistentHost = TermindSchemaV24HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV26HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 76f0bde0..102b390c 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -5,7 +5,7 @@ import SwiftData /// choose storage and own the container lifetime, never the model set. @MainActor public enum TermindPersistence { - public static let schema = Schema(versionedSchema: TermindSchemaV25.self) + public static let schema = Schema(versionedSchema: TermindSchemaV26.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") diff --git a/Frameworks/TermindPersistence/TermindSchemaV26HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV26HostModels.swift new file mode 100644 index 00000000..65d55f2b --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV26HostModels.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +/// V26 adds the optional Startup Snippet reference while preserving the Host entity name. +public enum TermindSchemaV26HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var port = 22 + public var identityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var startupSnippetID: UUID? + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var lastUsedAt: Date? + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + name = value.name + address = value.host + port = value.port + identityID = value.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, + identityID: identityID, groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index d7282429..69063b34 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -3,8 +3,21 @@ import TermindDomain public struct HostIntegrationSection: View { @Binding private var mode: HostShellIntegrationMode + @Binding private var startupSnippetID: Snippet.ID? + private let snippets: [Snippet] + private let snippetPackagePath: (SnippetPackage.ID?) -> String? - public init(mode: Binding) { _mode = mode } + public init( + mode: Binding, + startupSnippetID: Binding = .constant(nil), + snippets: [Snippet] = [], + snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil } + ) { + _mode = mode + _startupSnippetID = startupSnippetID + self.snippets = snippets + self.snippetPackagePath = snippetPackagePath + } public var body: some View { Section("Integration") { @@ -15,6 +28,25 @@ public struct HostIntegrationSection: View { } .pickerStyle(.menu) .menuOrder(.fixed) + + Picker("Startup Snippet", selection: $startupSnippetID) { + Text("None").tag(Snippet.ID?.none) + if !snippets.isEmpty { + Divider() + ForEach(snippets) { snippet in + Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id)) + } + } + } + .pickerStyle(.menu) + .menuOrder(.fixed) } } + + private func snippetLabel(_ snippet: Snippet) -> String { + guard let path = snippetPackagePath(snippet.packageID), !path.isEmpty else { + return snippet.title + } + return "\(path) / \(snippet.title)" + } } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 5b6e3778..6e52ce5c 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -207,6 +207,9 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r override/global once before auth for bootstrap/History/generator/system info/directory actions. Live edits never inject or reconnect; new reconnects resolve again. Mobile integration/exit uses one ordered MainActor consumer retired on close/ reconnect/expiration. Prompt markers are idempotent; Bash DEBUG excludes `__termind_*` helpers from History/busy state. +- Host Startup Snippet is a Vault-synced optional Snippet ID (V26). Every interactive SSH shell resolves the current body and + submits it once at the authenticated prompt boundary, including automatic reconnects; explicit launch commands override only + until submitted. Missing/empty Snippets are no-ops. SFTP, forwarding, Metrics, Agent and route connections never run it. - History starts after authenticated start, retains optional remote status/duration (never receive-time inference), normalizes backslash continuation only and caps at 1,000 rows/Host. Both UIs show first line plus ellipsis. diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 0afdfec3..5fe996b6 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -175,7 +175,12 @@ struct MobileHostEditor: View { MobileTagField(tags: $draft.tags, suggestions: library.allTags) } } - HostIntegrationSection(mode: $draft.shellIntegrationMode) + HostIntegrationSection( + mode: $draft.shellIntegrationMode, + startupSnippetID: startupSnippetChoice, + snippets: library.snippets, + snippetPackagePath: library.snippetPackagePath + ) } ) .sheet(item: $editorRoute) { route in @@ -234,6 +239,14 @@ struct MobileHostEditor: View { } } + private var startupSnippetChoice: Binding { + Binding { + draft.startupSnippetID.flatMap { library.snippet($0)?.id } + } set: { + draft.startupSnippetID = $0 + } + } + private var proxyKind: Binding { Binding { draft.proxy?.kind diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 11992432..95dcbd4a 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -109,6 +109,8 @@ final class MobileWorkspace: Identifiable { private var shellIntegrationAvailable = false private var isInitialCommandSubmissionReady = false private var pendingCommand: MobilePendingInitialCommand? + /// Re-resolved from the Host and Snippet library for each physical terminal shell. + private var pendingConnectionStartupCommand: MobilePendingInitialCommand? private var commandExcludedFromHistory: String? private var pendingInitialSFTPPath: String? var pendingInitialCommand: String? { pendingCommand?.command } @@ -505,6 +507,7 @@ final class MobileWorkspace: Identifiable { isShellPromptReady = false shellIntegrationAvailable = false isInitialCommandSubmissionReady = false + pendingConnectionStartupCommand = nil workingDirectory = nil commandExcludedFromHistory = nil commandDraft.cancel() @@ -601,6 +604,15 @@ final class MobileWorkspace: Identifiable { } isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled( globalSetting: terminalSettings.preferences.shellIntegration) + if descriptor.kind == .ssh, pendingCommand == nil, + let snippetID = host.startupSnippetID, + let body = hosts.snippet(snippetID)?.body, + !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + pendingConnectionStartupCommand = MobilePendingInitialCommand( + command: body, recordsInHistory: true + ) + } let route = hosts.route(for: host) journey.begin( host: host, route: route, @@ -824,17 +836,25 @@ final class MobileWorkspace: Identifiable { } } - /// Consumes the launch-only command before it is written. Reconnects and - /// restored descriptors therefore cannot replay a script. + /// Consumes the explicit launch command once, then the Startup Snippet that + /// was freshly resolved for this physical terminal shell. func consumePendingInitialCommand() -> String? { - guard let pendingCommand else { return nil } - self.pendingCommand = nil - if !pendingCommand.recordsInHistory { + let command: MobilePendingInitialCommand + if let explicit = pendingCommand { + command = explicit + pendingCommand = nil + } else if let startup = pendingConnectionStartupCommand { + command = startup + pendingConnectionStartupCommand = nil + } else { + return nil + } + if !command.recordsInHistory { commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations( - in: pendingCommand.command + in: command.command ) } - return pendingCommand.command + return command.command } private func submitPendingInitialCommandIfReady() { @@ -1014,6 +1034,7 @@ final class MobileWorkspace: Identifiable { activeHistoryID = nil pendingCommand = nil + pendingConnectionStartupCommand = nil pendingInitialSFTPPath = nil commandExcludedFromHistory = nil generation = UUID() diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 566dc992..69a37603 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -135,6 +135,72 @@ struct MobileCommandGeneratorTests { #expect(connections.shellIntegrationPreferences == [expected, !expected, expected]) } + @Test func startupSnippetUsesExplicitLaunchOnceThenRefreshesOnReconnect() async throws { + var (host, library) = try commandFixture() + var startup = Snippet(title: "Startup", body: "echo first") + host.startupSnippetID = startup.id + library.upsert(startup) + library.upsert(host) + let connections = MobileSSHCommandConnectionBox() + let workspace = MobileWorkspace( + descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), + hosts: library, + connectionServices: connections.services, + initialCommand: "echo explicit" + ) + defer { workspace.disconnect() } + + workspace.connect() + #expect(await eventually { workspace.connected }) + #expect(connections.writes.isEmpty) + await connections.sendShellEvents([ + .available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test") + ]) + #expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] }) + + startup.body = "echo latest" + library.upsert(startup) + await connections.finishSession(.failure(.socket("test disconnect")), session: 0) + #expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected }) + await connections.sendShellEvents( + [.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")], + session: 1 + ) + #expect( + await eventually { + connections.writes == [ + Data("echo explicit\r".utf8), + Data("echo latest\r".utf8), + ] + } + ) + } + + @Test func startupSnippetRunsImmediatelyWhenShellIntegrationIsDisabled() async throws { + var (host, library) = try commandFixture() + let startup = Snippet(title: "Startup", body: "export TERMIND_TEST=1") + host.startupSnippetID = startup.id + host.shellIntegrationMode = .disabled + library.upsert(startup) + library.upsert(host) + let connections = MobileSSHCommandConnectionBox() + let workspace = MobileWorkspace( + descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), + hosts: library, + connectionServices: connections.services + ) + defer { workspace.disconnect() } + + workspace.connect() + + #expect(await eventually { workspace.connected }) + #expect( + await eventually { + connections.writes == [Data("export TERMIND_TEST=1\r".utf8)] + } + ) + } + @Test func readsTheCurrentDraftAndInsertsWithoutExecuting() async throws { let (host, library) = try commandFixture() let connections = MobileSSHCommandConnectionBox() diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 8f1dcbed..6565f47c 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -221,7 +221,12 @@ struct HostEditorView: View { .foregroundStyle(.secondary) } - HostIntegrationSection(mode: $draft.shellIntegrationMode) + HostIntegrationSection( + mode: $draft.shellIntegrationMode, + startupSnippetID: startupSnippetChoice, + snippets: app.hostLibrary.snippets, + snippetPackagePath: app.hostLibrary.snippetPackagePath + ) if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Section("SSH Command") { @@ -339,6 +344,14 @@ struct HostEditorView: View { } } + private var startupSnippetChoice: Binding { + Binding { + draft.startupSnippetID.flatMap { app.hostLibrary.snippet($0)?.id } + } set: { + draft.startupSnippetID = $0 + } + } + private var jumpHostChoices: [Host] { app.hostLibrary.hosts.filter { candidate in candidate.id != draft.id diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index f5d88fbc..206efbc9 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -257,6 +257,8 @@ final class SSHSessionModel: Identifiable { var recordsInHistory: Bool } private var pendingInitialCommand: PendingInitialCommand? + /// Re-resolved from the Host and Snippet library for each physical terminal shell. + private var pendingConnectionStartupCommand: PendingInitialCommand? private var commandExcludedFromHistory: String? private var isInitialCommandSubmissionReady = false @@ -440,6 +442,7 @@ final class SSHSessionModel: Identifiable { isShellPromptReady = false shellIntegrationAvailable = false isInitialCommandSubmissionReady = false + pendingConnectionStartupCommand = nil activeShellCommand = nil commandDraft.cancel() invalidateCommandGeneration() @@ -470,7 +473,6 @@ final class SSHSessionModel: Identifiable { ssh = nil guard let host = connectionHost(hostID, using: store) else { connection.stopRecovery() - pendingInitialCommand = nil connectionAttemptID = nil status = .failed(String(localized: "The host is no longer available.")) connectionJourney.fail(String(localized: "The host is no longer available.")) @@ -478,6 +480,15 @@ final class SSHSessionModel: Identifiable { } isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled( globalSetting: DesktopSettingsKey.isShellIntegrationEnabled) + if pendingInitialCommand == nil, + let snippetID = host.startupSnippetID, + let body = store.snippet(snippetID)?.body, + !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + pendingConnectionStartupCommand = PendingInitialCommand( + command: body, recordsInHistory: true + ) + } homeResolution = isShellIntegrationEnabled ? .pending : .unavailable let route = connectionRoute?.hosts ?? store.route(for: host) let identity = store.identity(for: host) @@ -660,7 +671,6 @@ final class SSHSessionModel: Identifiable { ) return } - pendingInitialCommand = nil connectionAttemptID = nil handleConnectionAttemptFailure( error, store: store, reconnecting: reconnecting @@ -858,6 +868,7 @@ final class SSHSessionModel: Identifiable { homeResolution = .unavailable transferSnapshot = nil pendingInitialCommand = nil + pendingConnectionStartupCommand = nil commandExcludedFromHistory = nil pendingAuthentication = nil suppliedAuthentications.removeAll() @@ -995,7 +1006,6 @@ final class SSHSessionModel: Identifiable { if connection.hasConnectedOnce || reconnecting { scheduleReconnect(after: error.localizedDescription, using: store) } else { - pendingInitialCommand = nil status = .failed(error.localizedDescription) connectionJourney.fail( error.localizedDescription, at: (error as? TermindSSHError)?.routeHostID @@ -1263,11 +1273,20 @@ final class SSHSessionModel: Identifiable { } } - /// Submits the one-shot startup command. Keeping the History exclusion next - /// to the write ensures it is armed only when the command actually runs. + /// Submits an explicit one-shot launch command, or this connection's freshly + /// resolved Host startup command. Keeping the History exclusion next to the + /// write ensures it is armed only when the command actually runs. func submitPendingInitialCommand() { - guard let initialCommand = pendingInitialCommand else { return } - pendingInitialCommand = nil + let initialCommand: PendingInitialCommand + if let explicit = pendingInitialCommand { + initialCommand = explicit + pendingInitialCommand = nil + } else if let startup = pendingConnectionStartupCommand { + initialCommand = startup + pendingConnectionStartupCommand = nil + } else { + return + } if !initialCommand.recordsInHistory { commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations( in: initialCommand.command diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index d1777946..35dd78c4 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -64,4 +64,38 @@ struct HostShellIntegrationSessionTests { session.connect(using: HostLibraryModel()) #expect(!session.isShellIntegrationEnabled) } + + @Test func startupSnippetUsesExplicitLaunchOnceAndResolvesLatestBodyOnReconnect() { + var startup = Snippet(title: "Startup", body: "echo first") + let host = Host( + name: "Server", host: "server.invalid", startupSnippetID: startup.id + ) + let library = HostLibraryModel(hosts: [host], snippets: [startup]) + let session = SSHSessionModel( + hostID: host.id, title: host.name, initialCommand: "echo explicit" + ) + var submitted = Data() + session.inputSink = { submitted.append($0) } + defer { session.disconnect() } + + session.connect(using: library) + session.status = .connected + session.receiveShellIntegrationEvent( + .available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test") + ) + #expect(String(decoding: submitted, as: UTF8.self) == "echo explicit\r") + + session.disconnect() + startup.body = "echo latest" + library.upsert(startup) + session.connect(using: library) + session.status = .connected + session.receiveShellIntegrationEvent( + .available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test") + ) + #expect( + String(decoding: submitted, as: UTF8.self) + == "echo explicit\recho latest\r" + ) + } } diff --git a/TFMTests/SnippetFeatureTests.swift b/TFMTests/SnippetFeatureTests.swift index 0993a96a..ac458bdf 100644 --- a/TFMTests/SnippetFeatureTests.swift +++ b/TFMTests/SnippetFeatureTests.swift @@ -96,6 +96,38 @@ struct SnippetFeatureTests { #expect(store.snippet(snippet.id)?.targetHostIDs == [kept.id]) } + @Test + func deletingSnippetsClearsOnlyMatchingHostStartupReferences() { + let package = SnippetPackage(name: "Startup") + let direct = Snippet(title: "Direct", body: "echo direct") + let packaged = Snippet( + title: "Packaged", body: "echo packaged", packageID: package.id + ) + let retained = Snippet(title: "Retained", body: "echo retained") + let first = Host( + name: "First", host: "first.invalid", startupSnippetID: direct.id + ) + let second = Host( + name: "Second", host: "second.invalid", startupSnippetID: packaged.id + ) + let third = Host( + name: "Third", host: "third.invalid", startupSnippetID: retained.id + ) + let store = HostLibraryModel( + hosts: [first, second, third], snippets: [direct, packaged, retained], + snippetPackages: [package] + ) + + store.delete(snippetIDs: [direct.id]) + #expect(store.host(first.id)?.startupSnippetID == nil) + #expect(store.host(second.id)?.startupSnippetID == packaged.id) + #expect(store.host(third.id)?.startupSnippetID == retained.id) + + store.delete(snippetPackageID: package.id, keepSnippets: false) + #expect(store.host(second.id)?.startupSnippetID == nil) + #expect(store.host(third.id)?.startupSnippetID == retained.id) + } + @Test func snippetPersistenceRoundTripKeepsPackagesTargetsAndLocalExpansion() throws { let configuration = localModelConfiguration(