Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/MoshTerminalEngine.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

178 lines
6.7 KiB
Swift

import Foundation
import MoshBootstrap
import MoshCore
import TermindApplication
import TermindDomain
actor MoshTerminalEngine: ResumableTerminalEngine {
private let receive: EventHandler
private var session: MoshClientSession?
private var reader: Task<Void, Never>?
private var accessCheck: HostOperationAccessCheck?
private var beforeConnect: ConnectionWakeAction?
private var rollbackPID: Int32?
private var closed = false
init(receive: @escaping EventHandler) {
self.receive = receive
}
func prepare(_ context: ResumableTerminalEngineContext) async throws -> ResumableTerminalEnginePreparation {
accessCheck = context.request.configuration.accessCheck
beforeConnect = context.request.configuration.beforeConnect
if let payload = context.checkpoint {
let restored = try await MoshClientSession.restore(from: Self.decodeCheckpoint(payload))
guard !closed else {
await restored.stop()
throw CancellationError()
}
session = restored
} else {
let range = context.options.moshUDPPortRange.replacingOccurrences(of: "-", with: ":")
var command = "mosh-server new -s -c 256"
if !range.isEmpty { command += " -p \(range)" }
command += try context.startup.moshArguments
let variables = context.environment.map {
"'\($0.variable)=\($0.value.replacingOccurrences(of: "'", with: "'\\''"))'"
}
command = (["env"] + variables + ["LANG=C.UTF-8", "LC_ALL=C.UTF-8", command]).joined(separator: " ")
let result = try await context.bootstrap.execute(
command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 65_536, onOutput: nil)
let info: MoshServerConnectInfo
do {
info = try MoshServerOutputParser.parse(result.stdout + result.stderr)
} catch {
throw TerminalTransportError.bootstrapFailed
}
rollbackPID = info.serverPID
try checkOpen()
session = MoshClientSession(
endpoint: .init(host: context.request.configuration.host, port: info.port, keyBase64_22: info.key))
}
return .init()
}
static func decodeCheckpoint(_ payload: Data) throws -> MoshSnapshot {
try JSONDecoder().decode(MoshSnapshot.self, from: payload)
}
func start(columns: UInt16, rows: UInt16) async throws {
try checkOpen()
guard let session else { throw TerminalTransportError.sessionFailed }
try await session.start()
try await session.enqueue(.resize(cols: Int32(columns), rows: Int32(rows)))
// An open UDP socket is not proof that the server is reachable.
let deadline = ContinuousClock.now.advanced(by: .seconds(20))
while true {
try checkOpen()
let operations = await session.drainHostOps()
if !operations.isEmpty {
await deliver(operations)
break
}
let state = await session.state
if case .failed = state { throw TerminalTransportError.sessionFailed }
if state == .stopped {
rollbackPID = nil
_ = await receivePoll(state: state, operations: session.drainHostOps())
return
}
guard ContinuousClock.now < deadline else { throw TerminalTransportError.unreachable }
try await Task.sleep(for: .milliseconds(20))
}
rollbackPID = nil
await receive(.connected)
try checkOpen()
reader = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
// Sample state before draining so the final poll includes trailing output.
let state = await session.state
guard await self.receivePoll(state: state, operations: session.drainHostOps()) else { return }
try? await Task.sleep(for: .milliseconds(16))
}
}
}
func receivePoll(state: MoshSessionState, operations: [MoshHostOp]) async -> Bool {
guard !closed else { return false }
await deliver(operations)
switch state {
case .stopped:
await receive(.ended)
return false
case .failed:
await receive(.failed)
return false
case .idle, .starting, .running, .suspending, .suspended, .stopping:
return true
}
}
private func deliver(_ operations: [MoshHostOp]) async {
guard !closed else { return }
if !operations.isEmpty { await receive(.activity) }
for operation in operations {
if case .hostBytes(let data) = operation { await receive(.output(data)) }
}
}
func write(_ data: Data) async throws {
try checkOpen()
try await session?.enqueue(.keystrokes(data))
}
func resize(columns: UInt16, rows: UInt16) async throws {
try checkOpen()
try await session?.enqueue(.resize(cols: Int32(columns), rows: Int32(rows)))
}
func checkpoint(shell: @Sendable () async -> ResumableTerminalShellState) async throws -> Data {
try checkOpen()
guard let session else { throw TerminalTransportError.invalidCheckpoint }
return try JSONEncoder().encode(await session.prepareForApplicationBackground())
}
func beforeResume() async throws {
try accessCheck?()
await beforeConnect?()
try checkOpen()
}
func resume() async throws {
try checkOpen()
try await session?.resumeFromApplicationBackground()
}
func retry() async throws {
try await beforeResume()
_ = try await session?.prepareForApplicationBackground()
try await session?.resumeFromApplicationBackground()
}
func rollback(using bootstrap: SSHConnection) async {
guard let pid = rollbackPID, pid > 1 else { return }
rollbackPID = nil
// Undo only the server launched by this attempt, even if the caller was cancelled.
await Task.detached {
_ = try? await bootstrap.execute(
"[ \"$(ps -p \(pid) -o comm=)\" = mosh-server ] && kill -TERM \(pid)",
workingDirectory: nil, stdin: nil, timeout: 3, outputLimit: 1024, onOutput: nil)
}.value
}
func close() async {
guard !closed else { return }
closed = true
reader?.cancel()
beforeConnect = nil
await session?.stop()
session = nil
}
private func checkOpen() throws {
try Task.checkCancellation()
guard !closed else { throw CancellationError() }
}
}