From b7b22ce2e533062f8fe490672a457aff402d42c5 Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 04:43:02 -0500 Subject: [PATCH 1/8] Refine mosh advanced settings and remove prediction plumbing --- .../SpecttyTransport/Mosh/MoshBootstrap.swift | 152 +++++++++++++++--- .../Mosh/MoshBootstrapOptions.swift | 47 ++++++ .../SpecttyTransport/Mosh/MoshTransport.swift | 13 +- .../SpecttyTransportTests/MoshTests.swift | 46 +++++- Spectty/Models/ServerConnection.swift | 69 ++++++++ Spectty/ViewModels/SessionManager.swift | 39 ++++- Spectty/Views/ConnectionEditorView.swift | 61 +++++++ 7 files changed, 394 insertions(+), 33 deletions(-) create mode 100644 Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrapOptions.swift diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift index 8587f80..3f00f83 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift @@ -19,7 +19,7 @@ enum MoshBootstrap { /// /// Matches the real mosh client's behavior: allocates a PTY (-tt) before /// exec, runs mosh-server directly, reads MOSH CONNECT, then closes SSH. - static func start(config: SSHConnectionConfig) async throws -> MoshSession { + static func start(config: SSHConnectionConfig, options: MoshBootstrapOptions = .init()) async throws -> MoshSession { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let authDelegate: NIOSSHClientUserAuthenticationDelegate = switch config.authMethod { @@ -92,27 +92,29 @@ enum MoshBootstrap { throw MoshError.bootstrapFailed("Failed to open SSH channel: \(error)") } - // Request PTY allocation before exec, matching the real mosh client's - // `-tt` SSH flag. This creates a proper session with a controlling - // terminal on the server side, which is how mosh-server expects to run. - let ptyRequest = SSHChannelRequestEvent.PseudoTerminalRequest( - wantReply: true, - term: "xterm-256color", - terminalCharacterWidth: 80, - terminalRowHeight: 24, - terminalPixelWidth: 0, - terminalPixelHeight: 0, - terminalModes: SSHTerminalModes([:]) - ) - let ptyPromise = childChannel.eventLoop.makePromise(of: Void.self) - childChannel.triggerUserOutboundEvent(ptyRequest, promise: ptyPromise) + if options.allocatePTY { + // Request PTY allocation before exec, matching the real mosh client's + // `-tt` SSH flag. This creates a proper session with a controlling + // terminal on the server side, which is how mosh-server expects to run. + let ptyRequest = SSHChannelRequestEvent.PseudoTerminalRequest( + wantReply: true, + term: "xterm-256color", + terminalCharacterWidth: 80, + terminalRowHeight: 24, + terminalPixelWidth: 0, + terminalPixelHeight: 0, + terminalModes: SSHTerminalModes([:]) + ) + let ptyPromise = childChannel.eventLoop.makePromise(of: Void.self) + childChannel.triggerUserOutboundEvent(ptyRequest, promise: ptyPromise) - do { - try await ptyPromise.futureResult.get() - } catch { - parentChannel.close(promise: nil) - group.shutdownGracefully { _ in } - throw MoshError.bootstrapFailed("PTY allocation failed: \(error)") + do { + try await ptyPromise.futureResult.get() + } catch { + parentChannel.close(promise: nil) + group.shutdownGracefully { _ in } + throw MoshError.bootstrapFailed("PTY allocation failed: \(error)") + } } // Exec mosh-server directly (like the real mosh client). @@ -125,8 +127,7 @@ enum MoshBootstrap { // packets. Prevents orphaned mosh-servers from accumulating when // the app crashes, is killed, or the network changes permanently. // During active use, heartbeats every 3s keep the server alive. - let bindAddr = config.host.contains(":") ? "::" : "0.0.0.0" - let command = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/snap/bin:/nix/var/nix/profiles/default/bin:$PATH\" MOSH_SERVER_NETWORK_TMOUT=600; exec mosh-server new -i \(bindAddr) -c 256 -l LANG=en_US.UTF-8" + let command = buildServerCommand(config: config, options: options) let execRequest = SSHChannelRequestEvent.ExecRequest( command: command, wantReply: true @@ -168,7 +169,14 @@ enum MoshBootstrap { // Parse "MOSH CONNECT " let parsed: ParsedConnect do { - parsed = try parseMoshConnect(output: output, host: config.host) + let defaultResolvedHost = parentChannel.remoteAddress?.ipAddress ?? config.host + let localResolvedHost = resolveHostLocally(host: config.host) + parsed = try parseMoshConnect( + output: output, + defaultHost: defaultResolvedHost, + localResolvedHost: localResolvedHost, + ipResolution: options.ipResolution + ) } catch { parentChannel.close(promise: nil) group.shutdownGracefully { _ in } @@ -269,7 +277,21 @@ enum MoshBootstrap { /// Parse mosh-server output for the MOSH CONNECT line. /// Handles PTY output which may contain `\r` characters from TTY line discipline. - static func parseMoshConnect(output: String, host: String) throws -> ParsedConnect { + static func parseMoshConnect( + output: String, + defaultHost: String, + localResolvedHost: String? = nil, + ipResolution: MoshIPResolution = .default + ) throws -> ParsedConnect { + let targetHost: String = switch ipResolution { + case .default: + defaultHost + case .local: + localResolvedHost ?? defaultHost + case .remote: + parseRemoteHostFromSSHConnection(output: output) ?? defaultHost + } + for line in output.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("MOSH CONNECT") { @@ -281,11 +303,89 @@ enum MoshBootstrap { throw MoshError.bootstrapFailed("Invalid port in MOSH CONNECT: \(parts[2])") } let key = String(parts[3]) - return ParsedConnect(host: host, udpPort: port, key: key) + return ParsedConnect(host: targetHost, udpPort: port, key: key) } } throw MoshError.bootstrapFailed("No MOSH CONNECT line in server output: \(output)") } + + private static func parseRemoteHostFromSSHConnection(output: String) -> String? { + for line in output.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("MOSH SSH_CONNECTION") else { continue } + let parts = trimmed.split(separator: " ") + // MOSH SSH_CONNECTION + guard parts.count >= 6 else { continue } + return String(parts[4]) + } + return nil + } + + private static func buildServerCommand(config: SSHConnectionConfig, options: MoshBootstrapOptions) -> String { + let bindAddr = bindAddress(for: config.host, family: options.bindFamily) + let serverPath = sanitized(options.serverPath) ?? "mosh-server" + + var args: [String] = ["new", "-i", bindAddr] + if let udpPortRange = sanitizedUDPPortRange(options.udpPortRange) { + args.append(contentsOf: ["-p", udpPortRange]) + } + args.append(contentsOf: ["-c", "256", "-l", "LANG=en_US.UTF-8"]) + + let env = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/snap/bin:/nix/var/nix/profiles/default/bin:$PATH\" MOSH_SERVER_NETWORK_TMOUT=600;" + let runServer = "exec \(shellQuote(serverPath)) \(args.joined(separator: " "))" + if options.ipResolution == .remote { + return "\(env) echo \"MOSH SSH_CONNECTION $SSH_CONNECTION\"; \(runServer)" + } else { + return "\(env) \(runServer)" + } + } + + private static func bindAddress(for host: String, family: MoshBindFamily) -> String { + switch family { + case .automatic: + return host.contains(":") ? "::" : "0.0.0.0" + case .ipv4: + return "0.0.0.0" + case .ipv6: + return "::" + } + } + + private static func sanitized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func resolveHostLocally(host: String) -> String? { + let trimmedHost = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + + if let parsed = try? SocketAddress(ipAddress: trimmedHost, port: 0), + let ip = parsed.ipAddress { + return ip + } + + if let resolved = try? SocketAddress.makeAddressResolvingHost(trimmedHost, port: 0), + let ip = resolved.ipAddress { + return ip + } + + return nil + } + + private static func sanitizedUDPPortRange(_ value: String?) -> String? { + guard let value = sanitized(value) else { return nil } + let allowed = CharacterSet(charactersIn: "0123456789:") + guard value.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + return nil + } + return value + } + + private static func shellQuote(_ raw: String) -> String { + // POSIX shell single-quote escaping. + "'\(raw.replacingOccurrences(of: "'", with: "'\\''"))'" + } } // MARK: - ExecOutputCollector diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrapOptions.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrapOptions.swift new file mode 100644 index 0000000..480a5b8 --- /dev/null +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrapOptions.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Address family preference for `mosh-server new -i`. +public enum MoshBindFamily: String, Codable, Sendable { + case automatic + case ipv4 + case ipv6 +} + +/// Strategy for selecting the UDP target host after bootstrap. +public enum MoshIPResolution: String, Codable, Sendable { + case `default` + case local + case remote +} + +/// Bootstrap controls for Mosh connection setup. +public struct MoshBootstrapOptions: Sendable { + /// Optional remote path for `mosh-server` binary. + public var serverPath: String? + + /// Optional UDP port or range (e.g. "60001" or "60001:60010"). + public var udpPortRange: String? + + /// Whether to request an SSH PTY before running mosh-server. + public var allocatePTY: Bool + + /// Requested bind family for mosh-server `-i`. + public var bindFamily: MoshBindFamily + + /// UDP target IP selection strategy. + public var ipResolution: MoshIPResolution + + public init( + serverPath: String? = nil, + udpPortRange: String? = nil, + allocatePTY: Bool = true, + bindFamily: MoshBindFamily = .automatic, + ipResolution: MoshIPResolution = .default + ) { + self.serverPath = serverPath + self.udpPortRange = udpPortRange + self.allocatePTY = allocatePTY + self.bindFamily = bindFamily + self.ipResolution = ipResolution + } +} diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift index 1da58e0..0f1cbfc 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift @@ -37,6 +37,7 @@ public final class MoshTransport: ResumableTransport, @unchecked Sendable { public let incomingData: AsyncStream private let config: SSHConnectionConfig + private let bootstrapOptions: MoshBootstrapOptions private let stateContinuation: AsyncStream.Continuation private let dataContinuation: AsyncStream.Continuation @@ -52,8 +53,9 @@ public final class MoshTransport: ResumableTransport, @unchecked Sendable { /// NAT type detected during pre-flight STUN check. Available after `connect()`. public nonisolated(unsafe) private(set) var detectedNATType: STUNClient.NATType? - public init(config: SSHConnectionConfig) { + public init(config: SSHConnectionConfig, bootstrapOptions: MoshBootstrapOptions = .init()) { self.config = config + self.bootstrapOptions = bootstrapOptions var sc: AsyncStream.Continuation! self.state = AsyncStream { sc = $0 } @@ -65,8 +67,13 @@ public final class MoshTransport: ResumableTransport, @unchecked Sendable { } /// Create a transport for resuming a saved session (skips SSH bootstrap). - public init(resuming savedState: MoshSessionState, config: SSHConnectionConfig) { + public init( + resuming savedState: MoshSessionState, + config: SSHConnectionConfig, + bootstrapOptions: MoshBootstrapOptions = .init() + ) { self.config = config + self.bootstrapOptions = bootstrapOptions self.savedState = savedState var sc: AsyncStream.Continuation! @@ -118,7 +125,7 @@ public final class MoshTransport: ResumableTransport, @unchecked Sendable { // SSH is closed after bootstrap; mosh communicates entirely over UDP. let session: MoshSession do { - session = try await MoshBootstrap.start(config: config) + session = try await MoshBootstrap.start(config: config, options: bootstrapOptions) } catch { stateContinuation.yield(.failed(error)) throw error diff --git a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift index 415131d..2d210cf 100644 --- a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift +++ b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift @@ -283,7 +283,7 @@ struct BootstrapTests { mosh-server (mosh 1.4.0) [build mosh 1.4.0] """ - let session = try MoshBootstrap.parseMoshConnect(output: output, host: "example.com") + let session = try MoshBootstrap.parseMoshConnect(output: output, defaultHost: "example.com") #expect(session.host == "example.com") #expect(session.udpPort == 60001) #expect(session.key == "ABCDEFGHIJKLMNOPQRSTUV") @@ -293,9 +293,51 @@ struct BootstrapTests { func throwsOnMissing() { let output = "some random server output\n" #expect(throws: MoshError.self) { - try MoshBootstrap.parseMoshConnect(output: output, host: "example.com") + try MoshBootstrap.parseMoshConnect(output: output, defaultHost: "example.com") } } + + @Test("Uses remote-reported IP when enabled") + func parsesRemoteReportedHost() throws { + let output = """ + + MOSH SSH_CONNECTION 198.51.100.22 60123 203.0.113.10 22 + MOSH CONNECT 60005 ZYXWVUTSRQPONMLKJIHGFE + + """ + let session = try MoshBootstrap.parseMoshConnect( + output: output, + defaultHost: "example.com", + ipResolution: .remote + ) + #expect(session.host == "203.0.113.10") + #expect(session.udpPort == 60005) + } + + @Test("Uses locally resolved host when configured") + func parsesLocalResolvedHost() throws { + let output = "MOSH CONNECT 60005 ZYXWVUTSRQPONMLKJIHGFE" + let session = try MoshBootstrap.parseMoshConnect( + output: output, + defaultHost: "example.com", + localResolvedHost: "198.51.100.9", + ipResolution: .local + ) + #expect(session.host == "198.51.100.9") + #expect(session.udpPort == 60005) + } + + @Test("Falls back to default host when remote-reported IP is missing") + func remoteFallbacksToDefaultHost() throws { + let output = "MOSH CONNECT 60005 ZYXWVUTSRQPONMLKJIHGFE" + let session = try MoshBootstrap.parseMoshConnect( + output: output, + defaultHost: "203.0.113.77", + ipResolution: .remote + ) + #expect(session.host == "203.0.113.77") + #expect(session.udpPort == 60005) + } } // MARK: - Fragment Framing Tests diff --git a/Spectty/Models/ServerConnection.swift b/Spectty/Models/ServerConnection.swift index 3f39a50..1cb9c63 100644 --- a/Spectty/Models/ServerConnection.swift +++ b/Spectty/Models/ServerConnection.swift @@ -19,6 +19,38 @@ enum AuthMethod: String, Codable, CaseIterable, Sendable { static let visibleCases: [AuthMethod] = [.password, .publicKey] } +/// Lightweight Mosh preset for balancing defaults vs advanced behavior. +enum MoshPreset: String, Codable, CaseIterable, Sendable { + case standard = "Standard" + case strictNetwork = "Strict Network" + case troubleshoot = "Troubleshoot" + + var summary: String { + switch self { + case .standard: + return "Default settings for normal networks." + case .strictNetwork: + return "Prefers IPv4 and local IP resolution to reduce mixed-stack issues." + case .troubleshoot: + return "Disables PTY, forces IPv4, and uses remote-reported IP for maximum compatibility." + } + } +} + +/// Address family to request for mosh-server bind (-i). +enum MoshBindFamilySetting: String, Codable, CaseIterable, Sendable { + case automatic = "Automatic" + case ipv4 = "IPv4" + case ipv6 = "IPv6" +} + +/// How UDP target IP is selected after bootstrap. +enum MoshIPResolutionSetting: String, Codable, CaseIterable, Sendable { + case `default` = "Default" + case local = "Local" + case remote = "Remote" +} + /// Persistent model for a saved server connection. @Model final class ServerConnection { @@ -45,6 +77,24 @@ final class ServerConnection { /// Command to run after connecting (e.g. "tmux new-session -A -s main"). var startupCommand: String? + /// Mosh UI preset for applying sensible defaults quickly. + var moshPreset: MoshPreset = MoshPreset.standard + + /// Optional override path to `mosh-server` on remote host. + var moshServerPath: String? + + /// Optional UDP port or range (e.g. "60001" or "60001:60010"). + var moshUDPPortRange: String? + + /// Compatibility mode: skips PTY for bootstrap. + var moshCompatibilityMode: Bool = false + + /// Requested mosh-server bind address family. + var moshBindFamily: MoshBindFamilySetting = MoshBindFamilySetting.automatic + + /// Host IP resolution strategy for UDP target selection. + var moshIPResolution: MoshIPResolutionSetting = MoshIPResolutionSetting.default + /// Sort order for the connection list. var sortOrder: Int @@ -75,4 +125,23 @@ final class ServerConnection { self.authMethod = authMethod self.sortOrder = 0 } + + /// Apply a preset and update related advanced Mosh settings. + func applyMoshPreset(_ preset: MoshPreset) { + moshPreset = preset + switch preset { + case .standard: + moshCompatibilityMode = false + moshBindFamily = .automatic + moshIPResolution = .default + case .strictNetwork: + moshCompatibilityMode = false + moshBindFamily = .ipv4 + moshIPResolution = .local + case .troubleshoot: + moshCompatibilityMode = true + moshBindFamily = .ipv4 + moshIPResolution = .remote + } + } } diff --git a/Spectty/ViewModels/SessionManager.swift b/Spectty/ViewModels/SessionManager.swift index bd7780c..f99b4d6 100644 --- a/Spectty/ViewModels/SessionManager.swift +++ b/Spectty/ViewModels/SessionManager.swift @@ -87,6 +87,7 @@ final class SessionManager { username: connection.username, authMethod: authMethod ) + let moshOptions = moshBootstrapOptions(for: connection) let transport: any TerminalTransport @@ -94,7 +95,7 @@ final class SessionManager { case .ssh: transport = SSHTransport(config: config) case .mosh: - transport = MoshTransport(config: config) + transport = MoshTransport(config: config, bootstrapOptions: moshOptions) } let scrollbackLines = UserDefaults.standard.integer(forKey: "scrollbackLines") @@ -104,7 +105,7 @@ final class SessionManager { case .ssh: return SSHTransport(config: config) case .mosh: - return MoshTransport(config: config) + return MoshTransport(config: config, bootstrapOptions: moshOptions) } } let session = TerminalSession( @@ -295,4 +296,38 @@ final class SessionManager { } } } + + private func moshBootstrapOptions(for connection: ServerConnection) -> MoshBootstrapOptions { + let bindFamily: MoshBindFamily = switch connection.moshBindFamily { + case .automatic: + .automatic + case .ipv4: + .ipv4 + case .ipv6: + .ipv6 + } + + let ipResolution: MoshIPResolution = switch connection.moshIPResolution { + case .default: + .default + case .local: + .local + case .remote: + .remote + } + + return MoshBootstrapOptions( + serverPath: normalizedOptional(connection.moshServerPath), + udpPortRange: normalizedOptional(connection.moshUDPPortRange), + allocatePTY: !connection.moshCompatibilityMode, + bindFamily: bindFamily, + ipResolution: ipResolution + ) + } + + private func normalizedOptional(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } diff --git a/Spectty/Views/ConnectionEditorView.swift b/Spectty/Views/ConnectionEditorView.swift index 81a19fa..deacfab 100644 --- a/Spectty/Views/ConnectionEditorView.swift +++ b/Spectty/Views/ConnectionEditorView.swift @@ -7,6 +7,7 @@ struct ConnectionEditorView: View { let isNew: Bool let onSave: (ServerConnection) -> Void @Environment(\.dismiss) private var dismiss + @State private var showMoshAdvanced = false @State private var keyValidationError: String? @State private var derivedPublicKey: String? @@ -120,6 +121,52 @@ struct ConnectionEditorView: View { .pickerStyle(.segmented) } + if connection.transport == .mosh { + Section { + DisclosureGroup("Mosh Advanced", isExpanded: $showMoshAdvanced) { + Picker("Preset", selection: $connection.moshPreset) { + ForEach(MoshPreset.allCases, id: \.self) { preset in + Text(preset.rawValue).tag(preset) + } + } + .onChange(of: connection.moshPreset, initial: false) { _, newPreset in + connection.applyMoshPreset(newPreset) + } + + Text(connection.moshPreset.summary) + .font(.caption) + .foregroundStyle(.secondary) + + TextField("mosh-server path (optional)", text: moshServerPathBinding) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + + TextField("UDP port or range (optional)", text: moshUDPPortBinding) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.numbersAndPunctuation) + .font(.system(.body, design: .monospaced)) + + Picker("IP Resolution", selection: $connection.moshIPResolution) { + ForEach(MoshIPResolutionSetting.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + + Toggle("Compatibility Mode (No SSH PTY)", isOn: $connection.moshCompatibilityMode) + + Picker("Bind Family", selection: $connection.moshBindFamily) { + ForEach(MoshBindFamilySetting.allCases, id: \.self) { family in + Text(family.rawValue).tag(family) + } + } + } + } footer: { + Text("Advanced settings for network edge cases and bootstrap troubleshooting.") + } + } + Section { TextField("e.g. tmux new-session -A -s main", text: startupCommandBinding) .textInputAutocapitalization(.never) @@ -172,6 +219,20 @@ struct ConnectionEditorView: View { ) } + private var moshServerPathBinding: Binding { + Binding( + get: { connection.moshServerPath ?? "" }, + set: { connection.moshServerPath = $0.isEmpty ? nil : $0 } + ) + } + + private var moshUDPPortBinding: Binding { + Binding( + get: { connection.moshUDPPortRange ?? "" }, + set: { connection.moshUDPPortRange = $0.isEmpty ? nil : $0 } + ) + } + private func validatePrivateKey() { let pem = connection.privateKeyPEM.trimmingCharacters(in: .whitespacesAndNewlines) guard !pem.isEmpty else { From 9a93ce6666afbb5bdd40086e965c0aac2c22b4dc Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 18:49:48 -0500 Subject: [PATCH 2/8] Add DEC charset and OSC 52 clipboard handling --- .../GhosttyTerminalEmulator.swift | 12 ++ .../SpecttyTerminal/VTStateMachine.swift | 113 +++++++++++++++++- Spectty/Models/TerminalSession.swift | 42 +++++-- 3 files changed, 148 insertions(+), 19 deletions(-) diff --git a/Packages/SpecttyTerminal/Sources/SpecttyTerminal/GhosttyTerminalEmulator.swift b/Packages/SpecttyTerminal/Sources/SpecttyTerminal/GhosttyTerminalEmulator.swift index 4710248..dc17813 100644 --- a/Packages/SpecttyTerminal/Sources/SpecttyTerminal/GhosttyTerminalEmulator.swift +++ b/Packages/SpecttyTerminal/Sources/SpecttyTerminal/GhosttyTerminalEmulator.swift @@ -14,6 +14,18 @@ public final class GhosttyTerminalEmulator: TerminalEmulator, @unchecked Sendabl set { vtStateMachine.onResponse = newValue } } + /// Called when remote sends clipboard data (OSC 52 set). + public var onSetClipboard: ((String) -> Void)? { + get { vtStateMachine.onSetClipboard } + set { vtStateMachine.onSetClipboard = newValue } + } + + /// Called when remote queries local clipboard (OSC 52 query). + public var onGetClipboard: (() -> String?)? { + get { vtStateMachine.onGetClipboard } + set { vtStateMachine.onGetClipboard = newValue } + } + public init(columns: Int = 80, rows: Int = 24, scrollbackCapacity: Int = 10_000) { self.state = TerminalState(columns: columns, rows: rows, scrollbackCapacity: scrollbackCapacity) self.vtStateMachine = VTStateMachine(state: self.state) diff --git a/Packages/SpecttyTerminal/Sources/SpecttyTerminal/VTStateMachine.swift b/Packages/SpecttyTerminal/Sources/SpecttyTerminal/VTStateMachine.swift index e48aab5..c52ef80 100644 --- a/Packages/SpecttyTerminal/Sources/SpecttyTerminal/VTStateMachine.swift +++ b/Packages/SpecttyTerminal/Sources/SpecttyTerminal/VTStateMachine.swift @@ -22,6 +22,11 @@ public final class VTStateMachine: @unchecked Sendable { case dcsPassthrough } + private enum DesignatedCharset { + case ascii + case decSpecialGraphics + } + private var parserState: ParserState = .ground private var params: [UInt16] = [] private var currentParam: UInt16 = 0 @@ -29,11 +34,20 @@ public final class VTStateMachine: @unchecked Sendable { private var intermediateChar: Character = "\0" private var oscPayload: [UInt8] = [] private var utf8Buffer: [UInt8] = [] + private var g0Charset: DesignatedCharset = .ascii + private var g1Charset: DesignatedCharset = .ascii + private var useG1Charset = false /// Called when the terminal needs to send a response back to the host /// (e.g., cursor position report, device attributes). public var onResponse: ((Data) -> Void)? + /// Called when remote requests clipboard update via OSC 52. + public var onSetClipboard: ((String) -> Void)? + + /// Called when remote queries clipboard content via OSC 52. + public var onGetClipboard: (() -> String?)? + public init(state: TerminalState) { self.terminalState = state } @@ -119,7 +133,7 @@ public final class VTStateMachine: @unchecked Sendable { case 0x0D: executeC0(byte) case 0x20...0x7E: - printChar(Character(UnicodeScalar(byte))) + printASCIIByte(byte) case 0x7F: break // DEL — ignore case 0xC0...0xDF: @@ -196,7 +210,7 @@ public final class VTStateMachine: @unchecked Sendable { case 0x20...0x2F: intermediateChar = Character(UnicodeScalar(byte)) case 0x30...0x7E: - // Dispatch escape sequence with intermediate. + designateCharset(intermediate: intermediateChar, final: byte) parserState = .ground default: parserState = .ground @@ -359,9 +373,9 @@ public final class VTStateMachine: @unchecked Sendable { case 0x0D: // CR screen.cursor.col = 0 case 0x0E: // SO (Shift Out) - break // G1 character set — not implemented + useG1Charset = true case 0x0F: // SI (Shift In) - break // G0 character set — not implemented + useG1Charset = false default: break } @@ -369,6 +383,50 @@ public final class VTStateMachine: @unchecked Sendable { // MARK: - Printing + private func printASCIIByte(_ byte: UInt8) { + let character = mappedASCIICharacter(byte, charset: useG1Charset ? g1Charset : g0Charset) + printChar(character) + } + + private func mappedASCIICharacter(_ byte: UInt8, charset: DesignatedCharset) -> Character { + guard charset == .decSpecialGraphics else { + return Character(UnicodeScalar(byte)) + } + + if let mapped = Self.decSpecialGraphicsMap[byte] { + return mapped + } + return Character(UnicodeScalar(byte)) + } + + private static let decSpecialGraphicsMap: [UInt8: Character] = [ + 0x60: "◆", + 0x61: "▒", + 0x66: "°", + 0x67: "±", + 0x6A: "┘", + 0x6B: "┐", + 0x6C: "┌", + 0x6D: "└", + 0x6E: "┼", + 0x6F: "⎺", + 0x70: "⎻", + 0x71: "─", + 0x72: "⎼", + 0x73: "⎽", + 0x74: "├", + 0x75: "┤", + 0x76: "┴", + 0x77: "┬", + 0x78: "│", + 0x79: "≤", + 0x7A: "≥", + 0x7B: "π", + 0x7C: "≠", + 0x7D: "£", + 0x7E: "·", + ] + private func printChar(_ char: Character) { let s = screen @@ -483,6 +541,28 @@ public final class VTStateMachine: @unchecked Sendable { terminalState.activeScreen = terminalState.primaryScreen terminalState.modes = [.autoWrap, .cursorVisible] terminalState.scrollback.clear() + g0Charset = .ascii + g1Charset = .ascii + useG1Charset = false + } + + private func designateCharset(intermediate: Character, final: UInt8) { + let target: DesignatedCharset + switch final { + case 0x30: // '0' — DEC Special Graphics + target = .decSpecialGraphics + default: + target = .ascii + } + + switch intermediate { + case "(": + g0Charset = target + case ")": + g1Charset = target + default: + break + } } // MARK: - CSI Dispatch @@ -994,8 +1074,7 @@ public final class VTStateMachine: @unchecked Sendable { case 1: // Set icon name — treat as title screen.title = data case 52: // Clipboard - // TODO: Handle clipboard access - break + handleOSC52(data) case 4: // Change/query color palette entry break case 10: // Set foreground color @@ -1009,6 +1088,28 @@ public final class VTStateMachine: @unchecked Sendable { } } + private func handleOSC52(_ data: String) { + let components = data.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false) + guard components.count == 2 else { return } + + let selection = String(components[0]) + let payload = String(components[1]) + + if payload == "?" { + guard let content = onGetClipboard?() else { return } + let encoded = Data(content.utf8).base64EncodedString() + let response = Data("\u{1B}]52;\(selection);\(encoded)\u{07}".utf8) + onResponse?(response) + return + } + + guard let decoded = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { + return + } + let text = String(decoding: decoded, as: UTF8.self) + onSetClipboard?(text) + } + // MARK: - UTF-8 Decoding private func decodeUTF8(_ bytes: [UInt8]) -> Unicode.Scalar? { diff --git a/Spectty/Models/TerminalSession.swift b/Spectty/Models/TerminalSession.swift index fc7862e..063429b 100644 --- a/Spectty/Models/TerminalSession.swift +++ b/Spectty/Models/TerminalSession.swift @@ -1,6 +1,9 @@ import Foundation import SpecttyTerminal import SpecttyTransport +#if canImport(UIKit) +import UIKit +#endif /// An active terminal session wiring together a transport and emulator. @Observable @@ -33,13 +36,7 @@ final class TerminalSession: Identifiable { self.transportFactory = transportFactory self.startupCommand = startupCommand - // Wire terminal responses (DSR, DA) back through the transport. - // Route through the same outbound queue to preserve ordering. - self.emulator.onResponse = { [weak self] data in - Task { @MainActor [weak self] in - self?.enqueueOutboundSend(data) - } - } + configureEmulatorCallbacks() } /// Start the session: connect and begin piping data. @@ -130,12 +127,7 @@ final class TerminalSession: Identifiable { let newTransport = transportFactory() self.transport = newTransport - // Re-wire emulator response handler - self.emulator.onResponse = { [weak self] data in - Task { @MainActor [weak self] in - self?.enqueueOutboundSend(data) - } - } + configureEmulatorCallbacks() // Connect and start streams (same as start()) try await newTransport.connect() @@ -195,6 +187,30 @@ final class TerminalSession: Identifiable { enqueueOutboundSend(Data((cmd + "\n").utf8)) } + private func configureEmulatorCallbacks() { + // Route terminal responses (DSR, DA, OSC queries) through outbound + // send ordering so responses and key input stay in sequence. + emulator.onResponse = { [weak self] data in + Task { @MainActor [weak self] in + self?.enqueueOutboundSend(data) + } + } + + emulator.onSetClipboard = { text in + #if canImport(UIKit) + UIPasteboard.general.string = text + #endif + } + + emulator.onGetClipboard = { + #if canImport(UIKit) + return UIPasteboard.general.string + #else + return nil + #endif + } + } + /// Queue outbound payloads so they are sent in-order, even when the UI /// generates bursts of key events (e.g. swipe typing). private func enqueueOutboundSend(_ data: Data) { From df0833140c923cc0d4c56cd5a7b9c420f855544c Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 18:49:53 -0500 Subject: [PATCH 3/8] Implement TOFU SSH host key verification --- .../SpecttyTransport/Mosh/MoshBootstrap.swift | 4 +- .../SSH/SSHAuthentication.swift | 61 ++++++++++++-- .../SSH/SSHHostKeyTrustStore.swift | 79 ++++++++++++++++++ .../SpecttyTransport/SSH/SSHTransport.swift | 9 +- .../SSHHostKeyTests.swift | 82 +++++++++++++++++++ 5 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift create mode 100644 Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift index 3f00f83..685f474 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift @@ -29,7 +29,7 @@ enum MoshBootstrap { SSHPublicKeyDelegate(username: config.username, privateKey: key) } nonisolated(unsafe) let authDelegateRef = authDelegate - let serverAuthDelegate = AcceptAllHostKeysDelegate() + let serverAuthDelegate = TOFUHostKeysDelegate(host: config.host, port: config.port) let bootstrap = ClientBootstrap(group: group) .channelInitializer { channel in @@ -218,7 +218,7 @@ enum MoshBootstrap { SSHPublicKeyDelegate(username: config.username, privateKey: key) } nonisolated(unsafe) let authDelegateRef = authDelegate - let serverAuthDelegate = AcceptAllHostKeysDelegate() + let serverAuthDelegate = TOFUHostKeysDelegate(host: config.host, port: config.port) let bootstrap = ClientBootstrap(group: group) .channelInitializer { channel in diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift index 64cad4a..4394237 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit import NIOCore import NIOSSH @@ -97,18 +98,62 @@ extension SSHPublicKeyDelegate: @unchecked Sendable {} // MARK: - Host Key Delegate -/// Placeholder server authentication delegate that accepts all host keys. -/// -/// **WARNING**: This is insecure. A real implementation should verify the -/// host key against a known-hosts database. -final class AcceptAllHostKeysDelegate: NIOSSHClientServerAuthenticationDelegate { +/// Server authentication delegate using TOFU (Trust On First Use). +final class TOFUHostKeysDelegate: NIOSSHClientServerAuthenticationDelegate { + private let host: String + private let port: Int + private let trustStore: SSHHostKeyTrustStore + + init(host: String, port: Int, trustStore: SSHHostKeyTrustStore = .shared) { + self.host = host + self.port = port + self.trustStore = trustStore + } + func validateHostKey( hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise ) { - // TODO: Replace with real host key verification (known_hosts, TOFU, etc.) - validationCompletePromise.succeed(()) + let presentedKey = String(openSSHPublicKey: hostKey) + + Task { + do { + let result = try await trustStore.validate(host: host, port: port, presentedKey: presentedKey) + validationCompletePromise.futureResult.eventLoop.execute { + switch result { + case .trusted: + validationCompletePromise.succeed(()) + case .mismatch(let expected, let presented): + validationCompletePromise.fail( + SSHTransportError.hostKeyMismatch( + host: self.host, + port: self.port, + expectedFingerprint: Self.fingerprint(forOpenSSHKey: expected), + presentedFingerprint: Self.fingerprint(forOpenSSHKey: presented) + ) + ) + } + } + } catch { + validationCompletePromise.futureResult.eventLoop.execute { + validationCompletePromise.fail( + SSHTransportError.hostKeyTrustStoreFailed(error.localizedDescription) + ) + } + } + } + } + + private static func fingerprint(forOpenSSHKey openSSHKey: String) -> String { + let parts = openSSHKey.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true) + guard parts.count >= 2, + let keyData = Data(base64Encoded: String(parts[1])) else { + return "unknown" + } + + let digest = SHA256.hash(data: keyData) + return Data(digest).base64EncodedString() } } -extension AcceptAllHostKeysDelegate: @unchecked Sendable {} +extension TOFUHostKeysDelegate: @unchecked Sendable {} diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift new file mode 100644 index 0000000..5d84c4b --- /dev/null +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Persistent host-key store used for TOFU (Trust On First Use) verification. +actor SSHHostKeyTrustStore { + enum ValidationResult: Sendable { + case trusted + case mismatch(expected: String, presented: String) + } + + static let shared = SSHHostKeyTrustStore() + + private let fileURL: URL + private var entries: [String: String] = [:] + private var didLoad = false + + init(fileURL: URL = SSHHostKeyTrustStore.defaultStoreURL()) { + self.fileURL = fileURL + } + + func validate(host: String, port: Int, presentedKey: String) throws -> ValidationResult { + try loadIfNeeded() + + let key = Self.hostIdentifier(host: host, port: port) + if let existing = entries[key] { + if existing == presentedKey { + return .trusted + } + return .mismatch(expected: existing, presented: presentedKey) + } + + entries[key] = presentedKey + try persist() + return .trusted + } + + private func loadIfNeeded() throws { + guard !didLoad else { return } + defer { didLoad = true } + + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: fileURL.path) else { + entries = [:] + return + } + + let data = try Data(contentsOf: fileURL) + entries = try JSONDecoder().decode([String: String].self, from: data) + } + + private func persist() throws { + let fileManager = FileManager.default + let directory = fileURL.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + + let data = try JSONEncoder().encode(entries) + try data.write(to: fileURL, options: .atomic) + } + + nonisolated static func hostIdentifier(host: String, port: Int) -> String { + let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalizedHost.contains(":") && !normalizedHost.hasPrefix("[") { + return "[\(normalizedHost)]:\(port)" + } + return "\(normalizedHost):\(port)" + } + + nonisolated static func defaultStoreURL() -> URL { + let fileManager = FileManager.default + if let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + return appSupport + .appendingPathComponent("Spectty", isDirectory: true) + .appendingPathComponent("ssh_known_hosts.json", isDirectory: false) + } + + return fileManager.temporaryDirectory + .appendingPathComponent("Spectty", isDirectory: true) + .appendingPathComponent("ssh_known_hosts.json", isDirectory: false) + } +} diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift index 4e9b67e..86221bd 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift @@ -23,6 +23,8 @@ public enum SSHTransportError: Error, LocalizedError { case notConnected case alreadyConnected case authenticationFailed + case hostKeyMismatch(host: String, port: Int, expectedFingerprint: String, presentedFingerprint: String) + case hostKeyTrustStoreFailed(String) case channelCreationFailed case connectionClosed case connectionFailed(String) @@ -32,6 +34,10 @@ public enum SSHTransportError: Error, LocalizedError { case .notConnected: return "SSH transport is not connected" case .alreadyConnected: return "SSH transport is already connected" case .authenticationFailed: return "SSH authentication failed — check your credentials" + case .hostKeyMismatch(let host, let port, let expectedFingerprint, let presentedFingerprint): + return "SSH host key mismatch for \(host):\(port) (expected SHA256:\(expectedFingerprint), got SHA256:\(presentedFingerprint))" + case .hostKeyTrustStoreFailed(let detail): + return "SSH host key verification failed: \(detail)" case .channelCreationFailed: return "Failed to create SSH channel" case .connectionClosed: return "SSH connection was closed" case .connectionFailed(let detail): return "SSH connection failed: \(detail)" @@ -113,7 +119,7 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable { stateContinuation.yield(.connecting) nonisolated(unsafe) let authDelegate = makeAuthDelegate() - let serverAuthDelegate = AcceptAllHostKeysDelegate() + let serverAuthDelegate = TOFUHostKeysDelegate(host: config.host, port: config.port) // Build the NIO client bootstrap with the SSH handler. let bootstrap = ClientBootstrap(group: eventLoopGroup) @@ -325,4 +331,3 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable { try await shellPromise.futureResult.get() } } - diff --git a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift new file mode 100644 index 0000000..d61fbfa --- /dev/null +++ b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import SpecttyTransport + +@Suite("SSH Host Key TOFU") +struct SSHHostKeyTests { + private let key1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJfkNV4OS33ImTXvorZr72q4v5XhVEQKfvqsxOEJ/XaR" + private let key2 = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIZS1APJofiPeoATC/VC4kKi7xRPdz934nSkFLTc0whYi3A8hEKHAOX9edgL1UWxRqRGQZq2wvvAIjAO9kCeiQA=" + + @Test("Trusts first seen key and accepts it on reconnect") + func trustsFirstUse() async throws { + let storeURL = makeTemporaryStoreURL() + defer { cleanupTemporaryStore(at: storeURL) } + + let store = SSHHostKeyTrustStore(fileURL: storeURL) + + let first = try await store.validate(host: "example.com", port: 22, presentedKey: key1) + switch first { + case .trusted: + break + case .mismatch: + Issue.record("First seen host key should be trusted") + } + + let second = try await store.validate(host: "example.com", port: 22, presentedKey: key1) + switch second { + case .trusted: + break + case .mismatch: + Issue.record("Previously trusted host key should still be accepted") + } + } + + @Test("Rejects changed key for an already trusted host") + func rejectsChangedHostKey() async throws { + let storeURL = makeTemporaryStoreURL() + defer { cleanupTemporaryStore(at: storeURL) } + + let store = SSHHostKeyTrustStore(fileURL: storeURL) + _ = try await store.validate(host: "example.com", port: 22, presentedKey: key1) + + let changed = try await store.validate(host: "example.com", port: 22, presentedKey: key2) + switch changed { + case .trusted: + Issue.record("Changed host key should be rejected") + case .mismatch(let expected, let presented): + #expect(expected == key1) + #expect(presented == key2) + } + } + + @Test("Persists trusted keys to disk") + func persistsTrustedKeys() async throws { + let storeURL = makeTemporaryStoreURL() + defer { cleanupTemporaryStore(at: storeURL) } + + do { + let store = SSHHostKeyTrustStore(fileURL: storeURL) + _ = try await store.validate(host: "example.com", port: 22, presentedKey: key1) + } + + let reloadedStore = SSHHostKeyTrustStore(fileURL: storeURL) + let revalidated = try await reloadedStore.validate(host: "example.com", port: 22, presentedKey: key1) + + switch revalidated { + case .trusted: + break + case .mismatch: + Issue.record("Reloaded trust store should recognize persisted host key") + } + } + + private func makeTemporaryStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("spectty-hostkeys-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("known_hosts.json") + } + + private func cleanupTemporaryStore(at url: URL) { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } +} From af6889174f1631288ecb3183023926d319de8903 Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 18:50:01 -0500 Subject: [PATCH 4/8] Add staged SwiftData migration plan with smoke tests --- Spectty.xcodeproj/project.pbxproj | 143 ++++++ .../xcshareddata/xcschemes/Spectty.xcscheme | 111 +++++ Spectty/Models/ServerConnection.swift | 455 ++++++++++++++---- Spectty/SpecttyApp.swift | 11 +- Spectty/ViewModels/ConnectionStore.swift | 30 +- Spectty/Views/ConnectionEditorView.swift | 12 +- .../ServerConnectionMigrationSmokeTests.swift | 98 ++++ 7 files changed, 760 insertions(+), 100 deletions(-) create mode 100644 Spectty.xcodeproj/xcshareddata/xcschemes/Spectty.xcscheme create mode 100644 SpecttyTests/ServerConnectionMigrationSmokeTests.swift diff --git a/Spectty.xcodeproj/project.pbxproj b/Spectty.xcodeproj/project.pbxproj index a71c45f..89d9ab4 100644 --- a/Spectty.xcodeproj/project.pbxproj +++ b/Spectty.xcodeproj/project.pbxproj @@ -13,11 +13,24 @@ A0000001AAAA000000000004 /* SpecttyKeychain in Frameworks */ = {isa = PBXBuildFile; productRef = A0000002AAAA000000000004 /* SpecttyKeychain */; }; A0000001AAAA000000000005 /* AcknowList in Frameworks */ = {isa = PBXBuildFile; productRef = A0000002AAAA000000000005 /* AcknowList */; }; A0000001AAAA000000000006 /* Package.resolved in Resources */ = {isa = PBXBuildFile; fileRef = A0000004AAAA000000000001 /* Package.resolved */; }; + B0000004AAAA000000000001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0000003AAAA000000000001 /* XCTest.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + B0000006AAAA000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8E69C22A2F40460400614CEC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8E69C2312F40460400614CEC; + remoteInfo = Spectty; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 8E69C2322F40460400614CEC /* Spectty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spectty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A0000004AAAA000000000001 /* Package.resolved */ = {isa = PBXFileReference; lastKnownFileType = text; name = Package.resolved; path = Spectty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; sourceTree = ""; }; + B0000001AAAA000000000001 /* SpecttyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpecttyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B0000003AAAA000000000001 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -26,6 +39,11 @@ path = Spectty; sourceTree = ""; }; + B0000002AAAA000000000001 /* SpecttyTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SpecttyTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -41,6 +59,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B0000005AAAA000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B0000004AAAA000000000001 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -48,6 +74,7 @@ isa = PBXGroup; children = ( 8E69C2342F40460400614CEC /* Spectty */, + B0000002AAAA000000000001 /* SpecttyTests */, A0000004AAAA000000000001 /* Package.resolved */, 8E69C2332F40460400614CEC /* Products */, ); @@ -57,6 +84,7 @@ isa = PBXGroup; children = ( 8E69C2322F40460400614CEC /* Spectty.app */, + B0000001AAAA000000000001 /* SpecttyTests.xctest */, ); name = Products; sourceTree = ""; @@ -91,6 +119,29 @@ productReference = 8E69C2322F40460400614CEC /* Spectty.app */; productType = "com.apple.product-type.application"; }; + B0000007AAAA000000000001 /* SpecttyTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B0000009AAAA000000000001 /* Build configuration list for PBXNativeTarget "SpecttyTests" */; + buildPhases = ( + B0000005AAAA000000000002 /* Sources */, + B0000005AAAA000000000001 /* Frameworks */, + B0000005AAAA000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B0000006AAAA000000000002 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + B0000002AAAA000000000001 /* SpecttyTests */, + ); + name = SpecttyTests; + packageProductDependencies = ( + ); + productName = SpecttyTests; + productReference = B0000001AAAA000000000001 /* SpecttyTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -104,6 +155,10 @@ 8E69C2312F40460400614CEC = { CreatedOnToolsVersion = 26.2; }; + B0000007AAAA000000000001 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 8E69C2312F40460400614CEC; + }; }; }; buildConfigurationList = 8E69C22D2F40460400614CEC /* Build configuration list for PBXProject "Spectty" */; @@ -128,6 +183,7 @@ projectRoot = ""; targets = ( 8E69C2312F40460400614CEC /* Spectty */, + B0000007AAAA000000000001 /* SpecttyTests */, ); }; /* End PBXProject section */ @@ -141,6 +197,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B0000005AAAA000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -151,8 +214,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B0000005AAAA000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + B0000006AAAA000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8E69C2312F40460400614CEC /* Spectty */; + targetProxy = B0000006AAAA000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8E69C23B2F40460500614CEC /* Debug */ = { isa = XCBuildConfiguration; @@ -345,6 +423,62 @@ }; name = Release; }; + B0000008AAAA000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T3755V9556; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.oceancheung.spectty-terminalTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Spectty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Spectty"; + TEST_TARGET_NAME = Spectty; + }; + name = Debug; + }; + B0000008AAAA000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T3755V9556; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.oceancheung.spectty-terminalTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Spectty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Spectty"; + TEST_TARGET_NAME = Spectty; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -366,6 +500,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B0000009AAAA000000000001 /* Build configuration list for PBXNativeTarget "SpecttyTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B0000008AAAA000000000001 /* Debug */, + B0000008AAAA000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/Spectty.xcodeproj/xcshareddata/xcschemes/Spectty.xcscheme b/Spectty.xcodeproj/xcshareddata/xcschemes/Spectty.xcscheme new file mode 100644 index 0000000..d332682 --- /dev/null +++ b/Spectty.xcodeproj/xcshareddata/xcschemes/Spectty.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Spectty/Models/ServerConnection.swift b/Spectty/Models/ServerConnection.swift index 1cb9c63..94c103f 100644 --- a/Spectty/Models/ServerConnection.swift +++ b/Spectty/Models/ServerConnection.swift @@ -51,97 +51,374 @@ enum MoshIPResolutionSetting: String, Codable, CaseIterable, Sendable { case remote = "Remote" } -/// Persistent model for a saved server connection. -@Model -final class ServerConnection { - var id: UUID - var name: String - var host: String - var port: Int - var username: String - var transport: TransportType - var authMethod: AuthMethod - - /// Keychain account name for the stored password (if password auth). - var passwordKeychainAccount: String? - - /// Keychain account name for the stored private key (if public key auth). - var privateKeyKeychainAccount: String? - - /// Terminal profile name to use. - var profileName: String? - - /// Last connected date. - var lastConnected: Date? - - /// Command to run after connecting (e.g. "tmux new-session -A -s main"). - var startupCommand: String? - - /// Mosh UI preset for applying sensible defaults quickly. - var moshPreset: MoshPreset = MoshPreset.standard - - /// Optional override path to `mosh-server` on remote host. - var moshServerPath: String? - - /// Optional UDP port or range (e.g. "60001" or "60001:60010"). - var moshUDPPortRange: String? - - /// Compatibility mode: skips PTY for bootstrap. - var moshCompatibilityMode: Bool = false - - /// Requested mosh-server bind address family. - var moshBindFamily: MoshBindFamilySetting = MoshBindFamilySetting.automatic - - /// Host IP resolution strategy for UDP target selection. - var moshIPResolution: MoshIPResolutionSetting = MoshIPResolutionSetting.default - - /// Sort order for the connection list. - var sortOrder: Int - - /// Transient password — not persisted to SwiftData, only lives in memory - /// for the duration of a session. - @Transient - var password: String = "" - - /// Transient private key PEM — not persisted to SwiftData, only lives in memory - /// for the duration of an editing session. - @Transient - var privateKeyPEM: String = "" - - init( - name: String = "", - host: String = "", - port: Int = 22, - username: String = "", - transport: TransportType = .ssh, - authMethod: AuthMethod = .password - ) { - self.id = UUID() - self.name = name - self.host = host - self.port = port - self.username = username - self.transport = transport - self.authMethod = authMethod - self.sortOrder = 0 +enum SpecttySchemaV1: VersionedSchema { + static var versionIdentifier: Schema.Version = .init(1, 0, 0) + static var models: [any PersistentModel.Type] { + [ServerConnection.self] } - /// Apply a preset and update related advanced Mosh settings. - func applyMoshPreset(_ preset: MoshPreset) { - moshPreset = preset - switch preset { - case .standard: - moshCompatibilityMode = false - moshBindFamily = .automatic - moshIPResolution = .default - case .strictNetwork: - moshCompatibilityMode = false - moshBindFamily = .ipv4 - moshIPResolution = .local - case .troubleshoot: - moshCompatibilityMode = true - moshBindFamily = .ipv4 - moshIPResolution = .remote + /// Persistent model for a saved server connection (pre-mosh fields). + @Model + final class ServerConnection { + var id: UUID + var name: String + var host: String + var port: Int + var username: String + var transport: TransportType + var authMethod: AuthMethod + + /// Keychain account name for the stored password (if password auth). + var passwordKeychainAccount: String? + + /// Keychain account name for the stored private key (if public key auth). + var privateKeyKeychainAccount: String? + + /// Terminal profile name to use. + var profileName: String? + + /// Last connected date. + var lastConnected: Date? + + /// Command to run after connecting (e.g. "tmux new-session -A -s main"). + var startupCommand: String? + + /// Sort order for the connection list. + var sortOrder: Int + + /// Transient password — not persisted to SwiftData, only lives in memory + /// for the duration of a session. + @Transient + var password: String = "" + + init( + name: String = "", + host: String = "", + port: Int = 22, + username: String = "", + transport: TransportType = .ssh, + authMethod: AuthMethod = .password + ) { + self.id = UUID() + self.name = name + self.host = host + self.port = port + self.username = username + self.transport = transport + self.authMethod = authMethod + self.sortOrder = 0 + } + } +} + +enum SpecttySchemaV2: VersionedSchema { + static var versionIdentifier: Schema.Version = .init(2, 0, 0) + static var models: [any PersistentModel.Type] { + [ServerConnection.self] + } + + /// Shipped schema with non-optional mosh fields. + @Model + final class ServerConnection { + var id: UUID + var name: String + var host: String + var port: Int + var username: String + var transport: TransportType + var authMethod: AuthMethod + + /// Keychain account name for the stored password (if password auth). + var passwordKeychainAccount: String? + + /// Keychain account name for the stored private key (if public key auth). + var privateKeyKeychainAccount: String? + + /// Terminal profile name to use. + var profileName: String? + + /// Last connected date. + var lastConnected: Date? + + /// Command to run after connecting (e.g. "tmux new-session -A -s main"). + var startupCommand: String? + + /// Mosh UI preset for applying sensible defaults quickly. + var moshPreset: MoshPreset = MoshPreset.standard + + /// Optional override path to `mosh-server` on remote host. + var moshServerPath: String? + + /// Optional UDP port or range (e.g. "60001" or "60001:60010"). + var moshUDPPortRange: String? + + /// Compatibility mode: skips PTY for bootstrap. + var moshCompatibilityMode: Bool = false + + /// Requested mosh-server bind address family. + var moshBindFamily: MoshBindFamilySetting = MoshBindFamilySetting.automatic + + /// Host IP resolution strategy for UDP target selection. + var moshIPResolution: MoshIPResolutionSetting = MoshIPResolutionSetting.default + + /// Sort order for the connection list. + var sortOrder: Int + + /// Transient password — not persisted to SwiftData, only lives in memory + /// for the duration of a session. + @Transient + var password: String = "" + init( + name: String = "", + host: String = "", + port: Int = 22, + username: String = "", + transport: TransportType = .ssh, + authMethod: AuthMethod = .password + ) { + self.id = UUID() + self.name = name + self.host = host + self.port = port + self.username = username + self.transport = transport + self.authMethod = authMethod + self.sortOrder = 0 + } + } +} + +enum SpecttySchemaV3: VersionedSchema { + static var versionIdentifier: Schema.Version = .init(3, 0, 0) + static var models: [any PersistentModel.Type] { + [ServerConnection.self] + } + + /// Transitional schema where mosh fields are optional for data repair. + @Model + final class ServerConnection { + var id: UUID + var name: String + var host: String + var port: Int + var username: String + var transport: TransportType + var authMethod: AuthMethod + + /// Keychain account name for the stored password (if password auth). + var passwordKeychainAccount: String? + + /// Keychain account name for the stored private key (if public key auth). + var privateKeyKeychainAccount: String? + + /// Terminal profile name to use. + var profileName: String? + + /// Last connected date. + var lastConnected: Date? + + /// Command to run after connecting (e.g. "tmux new-session -A -s main"). + var startupCommand: String? + + /// Mosh UI preset for applying sensible defaults quickly. + var moshPreset: MoshPreset? + + /// Optional override path to `mosh-server` on remote host. + var moshServerPath: String? + + /// Optional UDP port or range (e.g. "60001" or "60001:60010"). + var moshUDPPortRange: String? + + /// Compatibility mode: skips PTY for bootstrap. + var moshCompatibilityMode: Bool? + + /// Requested mosh-server bind address family. + var moshBindFamily: MoshBindFamilySetting? + + /// Host IP resolution strategy for UDP target selection. + var moshIPResolution: MoshIPResolutionSetting? + + /// Sort order for the connection list. + var sortOrder: Int + + /// Transient password — not persisted to SwiftData, only lives in memory + /// for the duration of a session. + @Transient + var password: String = "" + + init( + name: String = "", + host: String = "", + port: Int = 22, + username: String = "", + transport: TransportType = .ssh, + authMethod: AuthMethod = .password + ) { + self.id = UUID() + self.name = name + self.host = host + self.port = port + self.username = username + self.transport = transport + self.authMethod = authMethod + self.moshPreset = .standard + self.moshCompatibilityMode = false + self.moshBindFamily = .automatic + self.moshIPResolution = .default + self.sortOrder = 0 } } } + +enum SpecttySchemaV4: VersionedSchema { + static var versionIdentifier: Schema.Version = .init(4, 0, 0) + static var models: [any PersistentModel.Type] { + [ServerConnection.self] + } + + /// Current persistent model for a saved server connection. + @Model + final class ServerConnection { + var id: UUID + var name: String + var host: String + var port: Int + var username: String + var transport: TransportType + var authMethod: AuthMethod + + /// Keychain account name for the stored password (if password auth). + var passwordKeychainAccount: String? + + /// Keychain account name for the stored private key (if public key auth). + var privateKeyKeychainAccount: String? + + /// Terminal profile name to use. + var profileName: String? + + /// Last connected date. + var lastConnected: Date? + + /// Command to run after connecting (e.g. "tmux new-session -A -s main"). + var startupCommand: String? + + /// Mosh UI preset for applying sensible defaults quickly. + var moshPreset: MoshPreset = MoshPreset.standard + + /// Optional override path to `mosh-server` on remote host. + var moshServerPath: String? + + /// Optional UDP port or range (e.g. "60001" or "60001:60010"). + var moshUDPPortRange: String? + + /// Compatibility mode: skips PTY for bootstrap. + var moshCompatibilityMode: Bool = false + + /// Requested mosh-server bind address family. + var moshBindFamily: MoshBindFamilySetting = MoshBindFamilySetting.automatic + + /// Host IP resolution strategy for UDP target selection. + var moshIPResolution: MoshIPResolutionSetting = MoshIPResolutionSetting.default + + /// Internal schema marker so this version remains distinct for staged migration. + var migrationRevision: Int = 1 + + /// Sort order for the connection list. + var sortOrder: Int + + /// Transient password — not persisted to SwiftData, only lives in memory + /// for the duration of a session. + @Transient + var password: String = "" + + /// Transient private key PEM — not persisted to SwiftData, only lives in memory + /// for the duration of an editing session. + @Transient + var privateKeyPEM: String = "" + + init( + name: String = "", + host: String = "", + port: Int = 22, + username: String = "", + transport: TransportType = .ssh, + authMethod: AuthMethod = .password + ) { + self.id = UUID() + self.name = name + self.host = host + self.port = port + self.username = username + self.transport = transport + self.authMethod = authMethod + self.sortOrder = 0 + } + + /// Apply a preset and update related advanced Mosh settings. + func applyMoshPreset(_ preset: MoshPreset) { + moshPreset = preset + switch preset { + case .standard: + moshCompatibilityMode = false + moshBindFamily = .automatic + moshIPResolution = .default + case .strictNetwork: + moshCompatibilityMode = false + moshBindFamily = .ipv4 + moshIPResolution = .local + case .troubleshoot: + moshCompatibilityMode = true + moshBindFamily = .ipv4 + moshIPResolution = .remote + } + } + } +} + +enum SpecttyMigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [SpecttySchemaV1.self, SpecttySchemaV2.self, SpecttySchemaV3.self, SpecttySchemaV4.self] + } + + static var stages: [MigrationStage] { + [ + .lightweight(fromVersion: SpecttySchemaV1.self, toVersion: SpecttySchemaV2.self), + .lightweight(fromVersion: SpecttySchemaV2.self, toVersion: SpecttySchemaV3.self), + .custom( + fromVersion: SpecttySchemaV3.self, + toVersion: SpecttySchemaV4.self, + willMigrate: { context in + let descriptor = FetchDescriptor() + let connections = try context.fetch(descriptor) + var didChange = false + + for connection in connections { + if connection.moshPreset == nil { + connection.moshPreset = .standard + didChange = true + } + if connection.moshCompatibilityMode == nil { + connection.moshCompatibilityMode = false + didChange = true + } + if connection.moshBindFamily == nil { + connection.moshBindFamily = .automatic + didChange = true + } + if connection.moshIPResolution == nil { + connection.moshIPResolution = .default + didChange = true + } + } + + if didChange { + try context.save() + } + }, + didMigrate: nil + ) + ] + } +} + +typealias ServerConnection = SpecttySchemaV4.ServerConnection diff --git a/Spectty/SpecttyApp.swift b/Spectty/SpecttyApp.swift index 19fbe58..874cd75 100644 --- a/Spectty/SpecttyApp.swift +++ b/Spectty/SpecttyApp.swift @@ -8,10 +8,15 @@ struct SpecttyApp: App { @Environment(\.scenePhase) private var scenePhase var sharedModelContainer: ModelContainer = { - let schema = Schema([ServerConnection.self]) - let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + let schema = Schema(versionedSchema: SpecttySchemaV4.self) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: isRunningTests) do { - return try ModelContainer(for: schema, configurations: [config]) + return try ModelContainer( + for: schema, + migrationPlan: SpecttyMigrationPlan.self, + configurations: [config] + ) } catch { fatalError("Could not create ModelContainer: \(error)") } diff --git a/Spectty/ViewModels/ConnectionStore.swift b/Spectty/ViewModels/ConnectionStore.swift index bb2437b..d2b95c7 100644 --- a/Spectty/ViewModels/ConnectionStore.swift +++ b/Spectty/ViewModels/ConnectionStore.swift @@ -18,24 +18,46 @@ final class ConnectionStore { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.name)] ) - connections = (try? modelContext.fetch(descriptor)) ?? [] + do { + let fetched = try modelContext.fetch(descriptor) + connections = fetched + } catch { + connections = [] + logPersistenceError("Failed to fetch connections: \(error)") + } } func add(_ connection: ServerConnection) { connection.sortOrder = connections.count modelContext.insert(connection) - try? modelContext.save() + do { + try modelContext.save() + } catch { + logPersistenceError("Failed to save new connection: \(error)") + } fetchConnections() } func delete(_ connection: ServerConnection) { modelContext.delete(connection) - try? modelContext.save() + do { + try modelContext.save() + } catch { + logPersistenceError("Failed to delete connection: \(error)") + } fetchConnections() } func save() { - try? modelContext.save() + do { + try modelContext.save() + } catch { + logPersistenceError("Failed to save connections: \(error)") + } fetchConnections() } + + private func logPersistenceError(_ message: String) { + print("ConnectionStore: \(message)") + } } diff --git a/Spectty/Views/ConnectionEditorView.swift b/Spectty/Views/ConnectionEditorView.swift index deacfab..f2a6c83 100644 --- a/Spectty/Views/ConnectionEditorView.swift +++ b/Spectty/Views/ConnectionEditorView.swift @@ -124,14 +124,11 @@ struct ConnectionEditorView: View { if connection.transport == .mosh { Section { DisclosureGroup("Mosh Advanced", isExpanded: $showMoshAdvanced) { - Picker("Preset", selection: $connection.moshPreset) { + Picker("Preset", selection: moshPresetBinding) { ForEach(MoshPreset.allCases, id: \.self) { preset in Text(preset.rawValue).tag(preset) } } - .onChange(of: connection.moshPreset, initial: false) { _, newPreset in - connection.applyMoshPreset(newPreset) - } Text(connection.moshPreset.summary) .font(.caption) @@ -233,6 +230,13 @@ struct ConnectionEditorView: View { ) } + private var moshPresetBinding: Binding { + Binding( + get: { connection.moshPreset }, + set: { connection.applyMoshPreset($0) } + ) + } + private func validatePrivateKey() { let pem = connection.privateKeyPEM.trimmingCharacters(in: .whitespacesAndNewlines) guard !pem.isEmpty else { diff --git a/SpecttyTests/ServerConnectionMigrationSmokeTests.swift b/SpecttyTests/ServerConnectionMigrationSmokeTests.swift new file mode 100644 index 0000000..7a67760 --- /dev/null +++ b/SpecttyTests/ServerConnectionMigrationSmokeTests.swift @@ -0,0 +1,98 @@ +import XCTest +import SwiftData +@testable import Spectty + +final class ServerConnectionMigrationSmokeTests: XCTestCase { + func testV1StoreMigratesToLatestSchema() throws { + let storeURL = try makeStoreURL(testName: #function) + + let legacySchema = Schema(versionedSchema: SpecttySchemaV1.self) + let legacyConfig = ModelConfiguration(schema: legacySchema, url: storeURL) + let legacyContainer = try ModelContainer(for: legacySchema, configurations: [legacyConfig]) + let legacyContext = ModelContext(legacyContainer) + + let legacy = SpecttySchemaV1.ServerConnection( + name: "Legacy", + host: "legacy.example.com", + port: 22, + username: "ocean", + transport: .mosh, + authMethod: .password + ) + legacyContext.insert(legacy) + try legacyContext.save() + + let migratedContainer = try makeLatestContainer(storeURL: storeURL) + let migratedContext = ModelContext(migratedContainer) + let fetched = try migratedContext.fetch(FetchDescriptor()) + + XCTAssertEqual(fetched.count, 1) + let connection = try XCTUnwrap(fetched.first) + XCTAssertEqual(connection.name, "Legacy") + XCTAssertEqual(connection.host, "legacy.example.com") + XCTAssertEqual(connection.username, "ocean") + XCTAssertEqual(connection.moshPreset, .standard) + XCTAssertEqual(connection.moshCompatibilityMode, false) + XCTAssertEqual(connection.moshBindFamily, .automatic) + XCTAssertEqual(connection.moshIPResolution, .default) + } + + func testV3NilMoshFieldsAreBackfilledDuringMigration() throws { + let storeURL = try makeStoreURL(testName: #function) + + let transitionalSchema = Schema(versionedSchema: SpecttySchemaV3.self) + let transitionalConfig = ModelConfiguration(schema: transitionalSchema, url: storeURL) + let transitionalContainer = try ModelContainer(for: transitionalSchema, configurations: [transitionalConfig]) + let transitionalContext = ModelContext(transitionalContainer) + + let transitional = SpecttySchemaV3.ServerConnection( + name: "NeedsFix", + host: "nil.example.com", + port: 22, + username: "root", + transport: .mosh, + authMethod: .password + ) + transitional.moshPreset = nil + transitional.moshCompatibilityMode = nil + transitional.moshBindFamily = nil + transitional.moshIPResolution = nil + transitionalContext.insert(transitional) + try transitionalContext.save() + + let migratedContainer = try makeLatestContainer(storeURL: storeURL) + let migratedContext = ModelContext(migratedContainer) + let fetched = try migratedContext.fetch(FetchDescriptor()) + + XCTAssertEqual(fetched.count, 1) + let connection = try XCTUnwrap(fetched.first) + XCTAssertEqual(connection.name, "NeedsFix") + XCTAssertEqual(connection.moshPreset, .standard) + XCTAssertEqual(connection.moshCompatibilityMode, false) + XCTAssertEqual(connection.moshBindFamily, .automatic) + XCTAssertEqual(connection.moshIPResolution, .default) + } + + private func makeLatestContainer(storeURL: URL) throws -> ModelContainer { + let latestSchema = Schema(versionedSchema: SpecttySchemaV4.self) + let latestConfig = ModelConfiguration(schema: latestSchema, url: storeURL) + return try ModelContainer( + for: latestSchema, + migrationPlan: SpecttyMigrationPlan.self, + configurations: [latestConfig] + ) + } + + private func makeStoreURL(testName: String) throws -> URL { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appendingPathComponent("SpecttyMigrationSmoke-\(testName)-\(UUID().uuidString)", isDirectory: true) + + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { + try? fileManager.removeItem(at: directory) + } + + return directory.appendingPathComponent("Spectty.sqlite", isDirectory: false) + } +} From 32ac2f0fed8cc03b58f86a05dbefeef91e784e34 Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 19:11:43 -0500 Subject: [PATCH 5/8] Add host-key recovery flow and safer clipboard read defaults --- .../SpecttyTransport/Mosh/MoshBootstrap.swift | 5 +- .../SSH/SSHAuthentication.swift | 2 +- .../SSH/SSHHostKeyTrustStore.swift | 23 ++++++ .../SSHHostKeyTests.swift | 18 ++++ Spectty/Models/TerminalSession.swift | 3 + Spectty/Views/ConnectionListView.swift | 82 ++++++++++++++++++- Spectty/Views/SettingsView.swift | 3 + 7 files changed, 131 insertions(+), 5 deletions(-) diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift index 685f474..c79988b 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshBootstrap.swift @@ -53,6 +53,9 @@ enum MoshBootstrap { let parentChannel: Channel do { parentChannel = try await bootstrap.connect(host: config.host, port: config.port).get() + } catch let error as SSHTransportError { + group.shutdownGracefully { _ in } + throw error } catch { group.shutdownGracefully { _ in } throw MoshError.bootstrapFailed("SSH connection failed: \(error)") @@ -321,7 +324,7 @@ enum MoshBootstrap { return nil } - private static func buildServerCommand(config: SSHConnectionConfig, options: MoshBootstrapOptions) -> String { + static func buildServerCommand(config: SSHConnectionConfig, options: MoshBootstrapOptions) -> String { let bindAddr = bindAddress(for: config.host, family: options.bindFamily) let serverPath = sanitized(options.serverPath) ?? "mosh-server" diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift index 4394237..8c43186 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift @@ -152,7 +152,7 @@ final class TOFUHostKeysDelegate: NIOSSHClientServerAuthenticationDelegate { } let digest = SHA256.hash(data: keyData) - return Data(digest).base64EncodedString() + return Data(digest).base64EncodedString().trimmingCharacters(in: CharacterSet(charactersIn: "=")) } } diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift index 5d84c4b..fef50d6 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHHostKeyTrustStore.swift @@ -33,6 +33,13 @@ actor SSHHostKeyTrustStore { return .trusted } + func remove(host: String, port: Int) throws { + try loadIfNeeded() + let key = Self.hostIdentifier(host: host, port: port) + guard entries.removeValue(forKey: key) != nil else { return } + try persist() + } + private func loadIfNeeded() throws { guard !didLoad else { return } defer { didLoad = true } @@ -54,6 +61,14 @@ actor SSHHostKeyTrustStore { let data = try JSONEncoder().encode(entries) try data.write(to: fileURL, options: .atomic) + + // Ensure trusted-host metadata is encrypted at rest when the platform supports it. + #if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + #endif } nonisolated static func hostIdentifier(host: String, port: Int) -> String { @@ -77,3 +92,11 @@ actor SSHHostKeyTrustStore { .appendingPathComponent("ssh_known_hosts.json", isDirectory: false) } } + +/// Public API for host-key trust management (used by app UI for recovery flows). +public enum SSHHostKeyTrustManager { + /// Forget a previously trusted host key so the next connection can re-TOFU. + public static func forget(host: String, port: Int) async throws { + try await SSHHostKeyTrustStore.shared.remove(host: host, port: port) + } +} diff --git a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift index d61fbfa..6c2d85e 100644 --- a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift +++ b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/SSHHostKeyTests.swift @@ -70,6 +70,24 @@ struct SSHHostKeyTests { } } + @Test("Forgetting a trusted host allows trusting a replacement key") + func forgetsTrustedHostKey() async throws { + let storeURL = makeTemporaryStoreURL() + defer { cleanupTemporaryStore(at: storeURL) } + + let store = SSHHostKeyTrustStore(fileURL: storeURL) + _ = try await store.validate(host: "example.com", port: 22, presentedKey: key1) + try await store.remove(host: "example.com", port: 22) + + let replacement = try await store.validate(host: "example.com", port: 22, presentedKey: key2) + switch replacement { + case .trusted: + break + case .mismatch: + Issue.record("Forgotten host key should allow trusting replacement key") + } + } + private func makeTemporaryStoreURL() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("spectty-hostkeys-\(UUID().uuidString)", isDirectory: true) diff --git a/Spectty/Models/TerminalSession.swift b/Spectty/Models/TerminalSession.swift index 063429b..42ed427 100644 --- a/Spectty/Models/TerminalSession.swift +++ b/Spectty/Models/TerminalSession.swift @@ -204,6 +204,9 @@ final class TerminalSession: Identifiable { emulator.onGetClipboard = { #if canImport(UIKit) + guard UserDefaults.standard.bool(forKey: "allowRemoteClipboardRead") else { + return nil + } return UIPasteboard.general.string #else return nil diff --git a/Spectty/Views/ConnectionListView.swift b/Spectty/Views/ConnectionListView.swift index 58cbb1d..4ab1a4a 100644 --- a/Spectty/Views/ConnectionListView.swift +++ b/Spectty/Views/ConnectionListView.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import SpecttyKeychain +import SpecttyTransport struct ConnectionListView: View { @Environment(SessionManager.self) private var sessionManager @@ -16,6 +17,7 @@ struct ConnectionListView: View { @State private var showPasswordPrompt = false @State private var renamingSession: TerminalSession? @State private var sessionRenameText = "" + @State private var hostKeyRecovery: HostKeyRecoveryContext? var body: some View { NavigationStack { @@ -156,9 +158,22 @@ struct ConnectionListView: View { } .alert("Connection Failed", isPresented: .init( get: { connectionError != nil }, - set: { if !$0 { connectionError = nil } } + set: { + if !$0 { + connectionError = nil + hostKeyRecovery = nil + } + } )) { - Button("OK") { connectionError = nil } + if hostKeyRecovery != nil { + Button("Trust New Key & Retry") { + trustNewHostKeyAndReconnect() + } + } + Button("OK", role: .cancel) { + connectionError = nil + hostKeyRecovery = nil + } } message: { if let error = connectionError { Text(error) @@ -255,10 +270,65 @@ struct ConnectionListView: View { _ = try await sessionManager.connect(to: connection) connection.lastConnected = Date() connectionStore.save() + connectionError = nil + hostKeyRecovery = nil showCarousel = true } catch { - connectionError = String(describing: error) + let display = userFacingError(error) + if let recovery = hostKeyRecoveryContext(for: error, connection: connection) { + hostKeyRecovery = recovery + connectionError = """ + \(display) + + If you expected this change, tap "Trust New Key & Retry". + """ + } else { + hostKeyRecovery = nil + connectionError = display + } + } + } + + private func trustNewHostKeyAndReconnect() { + guard let recovery = hostKeyRecovery else { return } + hostKeyRecovery = nil + connectionError = nil + + Task { + do { + try await SSHHostKeyTrustManager.forget(host: recovery.host, port: recovery.port) + await doConnect(recovery.connection) + } catch { + connectionError = "Failed to forget trusted host key: \(userFacingError(error))" + } + } + } + + private func hostKeyRecoveryContext( + for error: any Error, + connection: ServerConnection + ) -> HostKeyRecoveryContext? { + if let transportError = error as? SSHTransportError, + case let .hostKeyMismatch(host, port, _, _) = transportError { + return HostKeyRecoveryContext(host: host, port: port, connection: connection) + } + + if let moshError = error as? MoshError, + case let .bootstrapFailed(detail) = moshError, + detail.localizedCaseInsensitiveContains("host key mismatch") { + return HostKeyRecoveryContext(host: connection.host, port: connection.port, connection: connection) } + + return nil + } + + private func userFacingError(_ error: any Error) -> String { + if let localizedError = error as? LocalizedError, + let description = localizedError.errorDescription, + !description.isEmpty { + return description + } + return String(describing: error) } private func quickConnect() { @@ -291,3 +361,9 @@ struct ConnectionListView: View { connectTo(connection) } } + +private struct HostKeyRecoveryContext { + let host: String + let port: Int + let connection: ServerConnection +} diff --git a/Spectty/Views/SettingsView.swift b/Spectty/Views/SettingsView.swift index 47f2dd6..b849ff0 100644 --- a/Spectty/Views/SettingsView.swift +++ b/Spectty/Views/SettingsView.swift @@ -7,6 +7,7 @@ struct SettingsView: View { @AppStorage("defaultColorScheme") private var colorScheme = "Default" @AppStorage("scrollbackLines") private var scrollbackLines = 10_000 @AppStorage("cursorStyle") private var cursorStyle = "block" + @AppStorage("allowRemoteClipboardRead") private var allowRemoteClipboardRead = false @AppStorage("privacyModeEnabled") private var privacyModeEnabled = false @AppStorage("biometricUnlockEnabled") private var biometricUnlockEnabled = false @@ -46,6 +47,8 @@ struct SettingsView: View { .multilineTextAlignment(.trailing) .frame(width: 80) } + + Toggle("Allow Remote Clipboard Read (OSC 52)", isOn: $allowRemoteClipboardRead) } Section("Security") { From 4c4478b67616ef4a214ab6554ea4f7cd820d47d3 Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 19:11:51 -0500 Subject: [PATCH 6/8] Add migration and command-construction coverage updates --- .../SpecttyKeychain/SSHKeyImporter.swift | 2 +- .../SpecttyTransportTests/MoshTests.swift | 39 +++++++++++++++++++ Spectty/ViewModels/ConnectionStore.swift | 4 +- .../ServerConnectionMigrationSmokeTests.swift | 38 ++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift b/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift index 9999a0a..8fa4ec6 100644 --- a/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift +++ b/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift @@ -48,7 +48,7 @@ public enum SSHKeyImportError: Error, Sendable { /// Parses OpenSSH private key files (the `-----BEGIN OPENSSH PRIVATE KEY-----` PEM format) /// and returns the raw key material suitable for use with CryptoKit. /// -/// Only **unencrypted** Ed25519 and ECDSA (P-256) keys are currently supported. +/// Only **unencrypted** Ed25519 and ECDSA (P-256/P-384) keys are currently supported. public struct SSHKeyImporter: Sendable { public init() {} diff --git a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift index 2d210cf..88297b3 100644 --- a/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift +++ b/Packages/SpecttyTransport/Tests/SpecttyTransportTests/MoshTests.swift @@ -338,6 +338,45 @@ struct BootstrapTests { #expect(session.host == "203.0.113.77") #expect(session.udpPort == 60005) } + + @Test("buildServerCommand quotes custom server path safely") + func buildServerCommandQuotesCustomPath() { + let config = SSHConnectionConfig( + host: "example.com", + username: "user", + authMethod: .password("pw") + ) + let options = MoshBootstrapOptions(serverPath: "/opt/custom path/mosh-server") + + let command = MoshBootstrap.buildServerCommand(config: config, options: options) + #expect(command.contains("exec '/opt/custom path/mosh-server' new -i 0.0.0.0")) + } + + @Test("buildServerCommand includes sanitized UDP port range") + func buildServerCommandIncludesValidPortRange() { + let config = SSHConnectionConfig( + host: "example.com", + username: "user", + authMethod: .password("pw") + ) + let options = MoshBootstrapOptions(udpPortRange: "60001:60010") + + let command = MoshBootstrap.buildServerCommand(config: config, options: options) + #expect(command.contains("-p 60001:60010")) + } + + @Test("buildServerCommand drops invalid UDP port range") + func buildServerCommandDropsInvalidPortRange() { + let config = SSHConnectionConfig( + host: "example.com", + username: "user", + authMethod: .password("pw") + ) + let options = MoshBootstrapOptions(udpPortRange: "60001;rm -rf /") + + let command = MoshBootstrap.buildServerCommand(config: config, options: options) + #expect(!command.contains("-p ")) + } } // MARK: - Fragment Framing Tests diff --git a/Spectty/ViewModels/ConnectionStore.swift b/Spectty/ViewModels/ConnectionStore.swift index d2b95c7..35b7791 100644 --- a/Spectty/ViewModels/ConnectionStore.swift +++ b/Spectty/ViewModels/ConnectionStore.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog import SwiftData /// Manages persistence of server connections using SwiftData. @@ -6,6 +7,7 @@ import SwiftData @MainActor final class ConnectionStore { private let modelContext: ModelContext + private let logger = Logger(subsystem: "com.oceancheung.spectty-terminal", category: "ConnectionStore") var connections: [ServerConnection] = [] @@ -58,6 +60,6 @@ final class ConnectionStore { } private func logPersistenceError(_ message: String) { - print("ConnectionStore: \(message)") + logger.error("\(message)") } } diff --git a/SpecttyTests/ServerConnectionMigrationSmokeTests.swift b/SpecttyTests/ServerConnectionMigrationSmokeTests.swift index 7a67760..5fc3499 100644 --- a/SpecttyTests/ServerConnectionMigrationSmokeTests.swift +++ b/SpecttyTests/ServerConnectionMigrationSmokeTests.swift @@ -37,6 +37,44 @@ final class ServerConnectionMigrationSmokeTests: XCTestCase { XCTAssertEqual(connection.moshIPResolution, .default) } + func testV2StoreMigratesToLatestSchema() throws { + let storeURL = try makeStoreURL(testName: #function) + + let v2Schema = Schema(versionedSchema: SpecttySchemaV2.self) + let v2Config = ModelConfiguration(schema: v2Schema, url: storeURL) + let v2Container = try ModelContainer(for: v2Schema, configurations: [v2Config]) + let v2Context = ModelContext(v2Container) + + let v2Connection = SpecttySchemaV2.ServerConnection( + name: "V2Connection", + host: "v2.example.com", + port: 22, + username: "v2user", + transport: .mosh, + authMethod: .password + ) + v2Connection.moshPreset = .strictNetwork + v2Connection.moshCompatibilityMode = false + v2Connection.moshBindFamily = .ipv4 + v2Connection.moshIPResolution = .local + v2Context.insert(v2Connection) + try v2Context.save() + + let migratedContainer = try makeLatestContainer(storeURL: storeURL) + let migratedContext = ModelContext(migratedContainer) + let fetched = try migratedContext.fetch(FetchDescriptor()) + + XCTAssertEqual(fetched.count, 1) + let connection = try XCTUnwrap(fetched.first) + XCTAssertEqual(connection.name, "V2Connection") + XCTAssertEqual(connection.host, "v2.example.com") + XCTAssertEqual(connection.username, "v2user") + XCTAssertEqual(connection.moshPreset, .strictNetwork) + XCTAssertEqual(connection.moshCompatibilityMode, false) + XCTAssertEqual(connection.moshBindFamily, .ipv4) + XCTAssertEqual(connection.moshIPResolution, .local) + } + func testV3NilMoshFieldsAreBackfilledDuringMigration() throws { let storeURL = try makeStoreURL(testName: #function) From 60aac8d711df339ce625218431bb15d6a120c8b8 Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 19:12:39 -0500 Subject: [PATCH 7/8] Remove duplicate mosh option normalization in SessionManager --- Spectty/ViewModels/SessionManager.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Spectty/ViewModels/SessionManager.swift b/Spectty/ViewModels/SessionManager.swift index f99b4d6..47e3bc0 100644 --- a/Spectty/ViewModels/SessionManager.swift +++ b/Spectty/ViewModels/SessionManager.swift @@ -317,17 +317,11 @@ final class SessionManager { } return MoshBootstrapOptions( - serverPath: normalizedOptional(connection.moshServerPath), - udpPortRange: normalizedOptional(connection.moshUDPPortRange), + serverPath: connection.moshServerPath, + udpPortRange: connection.moshUDPPortRange, allocatePTY: !connection.moshCompatibilityMode, bindFamily: bindFamily, ipResolution: ipResolution ) } - - private func normalizedOptional(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } } From bba8378f06ac2e5e6fd7fbf6d3bb59ceaa399cda Mon Sep 17 00:00:00 2001 From: ocnc Date: Sun, 1 Mar 2026 19:54:27 -0500 Subject: [PATCH 8/8] Bump app version to 1.2 --- Spectty.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Spectty.xcodeproj/project.pbxproj b/Spectty.xcodeproj/project.pbxproj index 89d9ab4..14ff8f7 100644 --- a/Spectty.xcodeproj/project.pbxproj +++ b/Spectty.xcodeproj/project.pbxproj @@ -375,7 +375,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = "com.oceancheung.spectty-terminal"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -410,7 +410,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = "com.oceancheung.spectty-terminal"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES;