fix: Eternal Terminal reconnecting
This commit is contained in:
1 parent
9f41e09e12
commit
7d4a73decc
6 files changed
+411
-80
No files matched your search
@@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import TermindPresentation
|
||||
|
||||
@Suite @MainActor
|
||||
struct ConnectionJourneyRouteStyleTests {
|
||||
@Test(
|
||||
arguments: [
|
||||
ConnectionPurpose.sshSession, .moshSession, .eternalTerminalSession,
|
||||
.sftpChannel, .metrics, .portForwarding, .keyExport,
|
||||
],
|
||||
[1, 2, 3]
|
||||
)
|
||||
func failureIncludesTheFailingEndpointAndIncomingLineButNotUntouchedRoute(
|
||||
purpose: ConnectionPurpose, failedIndex: Int
|
||||
) {
|
||||
let (journey, hosts) = fixture(purpose)
|
||||
for host in hosts.prefix(failedIndex - 1) {
|
||||
journey.receive(.connected(host.id))
|
||||
}
|
||||
let failedHost = hosts[failedIndex - 1]
|
||||
journey.receive(.connecting(failedHost.id))
|
||||
journey.fail("Transport failed", at: failedHost.id)
|
||||
|
||||
let style = ConnectionJourneyRouteStyle(journey: journey)
|
||||
// The failed endpoint was never completed; it and its incoming line must still be red.
|
||||
#expect(journey.completedEndpointIndex < failedIndex)
|
||||
#expect(style.state(for: .endpoint, at: failedIndex) == .failed)
|
||||
#expect(style.state(for: .segment, at: failedIndex) == .failed)
|
||||
expectRoute(
|
||||
journey,
|
||||
states: Array(repeating: .failed, count: failedIndex + 1)
|
||||
+ Array(repeating: .pending, count: hosts.count - failedIndex))
|
||||
}
|
||||
|
||||
@Test func dataConnectionFailureAfterSSHBootstrapRecolorsTheWholeRouteWithoutMovingFailureAttribution() {
|
||||
let (journey, hosts) = fixture(.eternalTerminalSession)
|
||||
completeBootstrap(journey, hosts: hosts)
|
||||
expectRoute(journey, states: [.complete, .complete, .complete, .complete])
|
||||
|
||||
journey.fail("ET data connection failed", at: hosts[0].id)
|
||||
|
||||
#expect(journey.activeEndpointIndex == 1)
|
||||
#expect(journey.logs.last?.endpoint == hosts[0].name)
|
||||
#expect(journey.completedCheckpoints[1] == .authentication)
|
||||
#expect(journey.completedCheckpoints[2] == .hostKey)
|
||||
#expect(journey.completedCheckpoints[3] == .authentication)
|
||||
expectRoute(journey, states: [.failed, .failed, .failed, .failed])
|
||||
}
|
||||
|
||||
@Test func completedCheckpointStillCountsAsProgressBeforeItsEndpointFinishesConnecting() {
|
||||
let (journey, hosts) = fixture(.sshSession)
|
||||
journey.receive(.connected(hosts[0].id))
|
||||
journey.waitForHostKey(at: hosts[1].id, fingerprint: "SHA256:test")
|
||||
journey.resume()
|
||||
journey.fail("Jump transport lost", at: hosts[0].id)
|
||||
|
||||
#expect(journey.completedEndpointIndex == 1)
|
||||
#expect(journey.completedCheckpoints[2] == .hostKey)
|
||||
expectRoute(journey, states: [.failed, .failed, .failed, .pending])
|
||||
}
|
||||
|
||||
@Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession])
|
||||
func recoveryFailureRecolorsLocalDeviceAllHopsAndAllLines(_ purpose: ConnectionPurpose) {
|
||||
let (journey, hosts) = fixture(purpose)
|
||||
completeBootstrap(journey, hosts: hosts)
|
||||
journey.succeed()
|
||||
journey.hide()
|
||||
journey.updateTransportRecovery(.suspended, message: "Paused")
|
||||
journey.updateTransportRecovery(.recovering, message: "Restoring")
|
||||
journey.updateTransportRecovery(.failed, message: "Recovery failed")
|
||||
|
||||
expectRoute(journey, states: [.failed, .failed, .failed, .failed])
|
||||
#expect(ConnectionJourneyRouteStyle(journey: journey).activityEndpointIndex == nil)
|
||||
}
|
||||
|
||||
@Test func localFailureDoesNotColorAnyRemoteEndpointOrLine() {
|
||||
let (journey, hosts) = fixture(.sshSession)
|
||||
journey.waitForLocalIdentity(at: hosts[0].id)
|
||||
journey.fail("No local identity")
|
||||
|
||||
expectRoute(journey, states: [.failed, .pending, .pending, .pending])
|
||||
}
|
||||
|
||||
@Test func proxyFailureIncludesItsSourceHopAndPreviouslyCompletedRoute() {
|
||||
let (journey, hosts) = fixture(.eternalTerminalSession)
|
||||
journey.receive(.connected(hosts[0].id))
|
||||
journey.waitForProxyAuthentication(at: hosts[1].id)
|
||||
journey.fail("Proxy rejected authentication")
|
||||
|
||||
expectRoute(journey, states: [.failed, .failed, .pending, .pending])
|
||||
}
|
||||
|
||||
@Test func directSerialAndTelnetFailuresUseTheSameInclusiveRule() {
|
||||
let journey = ConnectionJourney()
|
||||
journey.beginSerial(name: "Serial", address: "/dev/cu.test")
|
||||
journey.fail("Port unavailable")
|
||||
expectRoute(journey, states: [.failed, .failed])
|
||||
|
||||
let host = Host(name: "Telnet", host: "telnet.example.invalid")
|
||||
journey.beginTelnet(host: host, username: nil)
|
||||
journey.receive(.openingTransport)
|
||||
journey.fail("Transport unavailable")
|
||||
expectRoute(journey, states: [.failed, .failed])
|
||||
}
|
||||
|
||||
@Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession])
|
||||
func retryAndSuccessRemoveFailureColorsWithoutChangingRecoveryActivity(_ purpose: ConnectionPurpose) {
|
||||
let (journey, hosts) = fixture(purpose)
|
||||
completeBootstrap(journey, hosts: hosts)
|
||||
journey.updateTransportRecovery(.failed, message: "Recovery failed")
|
||||
expectRoute(journey, states: [.failed, .failed, .failed, .failed])
|
||||
|
||||
journey.updateTransportRecovery(.recovering, message: "Restoring")
|
||||
let style = ConnectionJourneyRouteStyle(journey: journey)
|
||||
#expect(style.state(for: .endpoint, at: 0) == .complete)
|
||||
#expect(style.state(for: .endpoint, at: 1) == .pending)
|
||||
#expect(style.state(for: .endpoint, at: 2) == .pending)
|
||||
#expect(style.state(for: .endpoint, at: 3) == .active)
|
||||
#expect(style.state(for: .segment, at: 1) == .pending)
|
||||
#expect(style.state(for: .segment, at: 2) == .pending)
|
||||
#expect(style.state(for: .segment, at: 3) == .active)
|
||||
#expect(style.activityEndpointIndex == 3)
|
||||
|
||||
journey.updateTransportRecovery(.suspended, message: "Paused")
|
||||
#expect(style.state(for: .segment, at: 3) == .pending)
|
||||
#expect(style.activityEndpointIndex == nil)
|
||||
|
||||
journey.succeed()
|
||||
expectRoute(journey, states: [.complete, .complete, .complete, .complete])
|
||||
#expect(style.activityEndpointIndex == nil)
|
||||
journey.hide()
|
||||
}
|
||||
|
||||
@Test func newConnectionAttemptClearsFailedRouteAndCheckpoints() {
|
||||
let (journey, hosts) = fixture(.eternalTerminalSession)
|
||||
completeBootstrap(journey, hosts: hosts)
|
||||
journey.fail("Data connection failed")
|
||||
journey.begin(host: hosts[2], route: hosts, purpose: .eternalTerminalSession, username: "user")
|
||||
|
||||
expectRoute(journey, states: [.complete, .pending, .pending, .pending])
|
||||
#expect(journey.completedCheckpoints.isEmpty)
|
||||
#expect(ConnectionJourneyRouteStyle(journey: journey).activityEndpointIndex == 0)
|
||||
}
|
||||
|
||||
private func fixture(_ purpose: ConnectionPurpose) -> (ConnectionJourney, [Host]) {
|
||||
let hosts = [
|
||||
Host(name: "Jump One", host: "jump-one.example.invalid"),
|
||||
Host(name: "Jump Two", host: "jump-two.example.invalid"),
|
||||
Host(name: "Destination", host: "destination.example.invalid"),
|
||||
]
|
||||
let journey = ConnectionJourney()
|
||||
journey.begin(host: hosts[2], route: hosts, purpose: purpose, username: "user")
|
||||
return (journey, hosts)
|
||||
}
|
||||
|
||||
private func completeBootstrap(_ journey: ConnectionJourney, hosts: [Host]) {
|
||||
for (index, host) in hosts.enumerated() {
|
||||
if index == 1 {
|
||||
journey.waitForHostKey(at: host.id, fingerprint: "SHA256:test")
|
||||
} else {
|
||||
journey.waitForAuthentication(at: host.id)
|
||||
}
|
||||
journey.resume()
|
||||
journey.receive(.connected(host.id))
|
||||
}
|
||||
}
|
||||
|
||||
private func expectRoute(_ journey: ConnectionJourney, states: [ConnectionJourneyRouteStyle.State]) {
|
||||
let style = ConnectionJourneyRouteStyle(journey: journey)
|
||||
#expect(journey.endpoints.count == states.count)
|
||||
for (index, expected) in states.enumerated() {
|
||||
#expect(style.state(for: .endpoint, at: index) == expected)
|
||||
if expected == .failed {
|
||||
#expect(style.state(for: .endpoint, at: index).color == .red)
|
||||
}
|
||||
if index > 0 {
|
||||
// Lines and retained checkpoint badges consume this same segment state and color.
|
||||
#expect(style.state(for: .segment, at: index) == expected)
|
||||
if expected == .failed {
|
||||
#expect(style.state(for: .segment, at: index).color == .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import ETSession
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
@Suite
|
||||
struct EternalTerminalResumeTests {
|
||||
@Test func foregroundResumeConfirmsAConnectionThatStayedAliveWithoutANewVendorEvent() async throws {
|
||||
let fixture = Fixture()
|
||||
await fixture.start()
|
||||
fixture.states.continuation.yield(.connected)
|
||||
#expect(await eventually { fixture.events.values == [.connected] })
|
||||
|
||||
// The coordinator enters recovery even when ET kept the same connection during suspension.
|
||||
await fixture.client.receiveEngineEvent(.recovering)
|
||||
try await fixture.engine.resume()
|
||||
#expect(fixture.events.values == [.connected, .recovering, .connected])
|
||||
|
||||
await fixture.close()
|
||||
}
|
||||
|
||||
@Test(arguments: [ETConnectionState.disconnected, .reconnecting, .failed(.transportFailure("offline"))])
|
||||
func foregroundResumeDoesNotConfirmALostConnection(_ state: ETConnectionState) async throws {
|
||||
let fixture = Fixture()
|
||||
await fixture.start()
|
||||
fixture.states.continuation.yield(.connected)
|
||||
#expect(await eventually { fixture.events.values == [.connected] })
|
||||
fixture.states.continuation.yield(state)
|
||||
#expect(await eventually { fixture.events.values.count == 2 })
|
||||
let beforeResume = fixture.events.values
|
||||
|
||||
try await fixture.engine.resume()
|
||||
#expect(fixture.events.values == beforeResume)
|
||||
|
||||
fixture.states.continuation.yield(.connected)
|
||||
#expect(await eventually { fixture.events.values == beforeResume + [.connected] })
|
||||
await fixture.close()
|
||||
}
|
||||
|
||||
@Test func foregroundResumeCannotReviveAnEndedOrLocallyClosedSession() async throws {
|
||||
let fixture = Fixture()
|
||||
await fixture.start()
|
||||
fixture.states.continuation.yield(.connected)
|
||||
#expect(await eventually { fixture.events.values == [.connected] })
|
||||
fixture.states.continuation.yield(.sessionEnded)
|
||||
fixture.output.continuation.finish()
|
||||
#expect(await eventually { fixture.events.values.last == .ended })
|
||||
let endedEvents = fixture.events.values
|
||||
|
||||
try await fixture.engine.resume()
|
||||
#expect(fixture.events.values == endedEvents)
|
||||
#expect(fixture.events.connectionConfirmations == 1)
|
||||
await fixture.close()
|
||||
await #expect(throws: CancellationError.self) { try await fixture.engine.resume() }
|
||||
#expect(fixture.events.values == endedEvents)
|
||||
#expect(fixture.events.connectionConfirmations == 1)
|
||||
}
|
||||
|
||||
@Test func foregroundResumeBeforeConnectionDoesNotReportSuccess() async throws {
|
||||
let fixture = Fixture()
|
||||
await fixture.start()
|
||||
try await fixture.engine.resume()
|
||||
#expect(fixture.events.values.isEmpty)
|
||||
#expect(fixture.events.connectionConfirmations == 0)
|
||||
await fixture.close()
|
||||
}
|
||||
|
||||
private func eventually(_ condition: () -> Bool) async -> Bool {
|
||||
for _ in 0..<200 {
|
||||
if condition() { return true }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
private struct Fixture {
|
||||
let events: Events
|
||||
let client: ResumableTerminalClient
|
||||
let engine: EternalTerminalEngine
|
||||
let output = AsyncStream<Data>.makeStream()
|
||||
let states = AsyncStream<ETConnectionState>.makeStream()
|
||||
|
||||
init() {
|
||||
let events = Events()
|
||||
let client = ResumableTerminalClient(
|
||||
transport: .eternalTerminal,
|
||||
output: { _ in },
|
||||
stateChanged: { events.record($0) },
|
||||
shellIntegration: { _ in })
|
||||
self.events = events
|
||||
self.client = client
|
||||
engine = EternalTerminalEngine {
|
||||
if case .connected = $0 { events.recordConnectionConfirmation() }
|
||||
await client.receiveEngineEvent($0)
|
||||
}
|
||||
}
|
||||
|
||||
func start() async {
|
||||
await engine.startReaders(output: output.stream, states: states.stream)
|
||||
}
|
||||
|
||||
func close() async {
|
||||
await client.close()
|
||||
await engine.close()
|
||||
output.continuation.finish()
|
||||
states.continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class Events: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var recorded: [ResumableTerminalState] = []
|
||||
private var confirmations = 0
|
||||
var values: [ResumableTerminalState] { lock.withLock { recorded } }
|
||||
var connectionConfirmations: Int { lock.withLock { confirmations } }
|
||||
|
||||
func record(_ state: ResumableTerminalState) {
|
||||
lock.withLock { recorded.append(state) }
|
||||
}
|
||||
|
||||
func recordConnectionConfirmation() {
|
||||
lock.withLock { confirmations += 1 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
|
||||
struct ConnectionJourneyRouteStyle {
|
||||
enum Element: Equatable {
|
||||
case endpoint
|
||||
case segment
|
||||
}
|
||||
|
||||
enum State: Equatable {
|
||||
case pending
|
||||
case active
|
||||
case complete
|
||||
case failed
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .pending: .secondary.opacity(0.55)
|
||||
case .active, .complete: .accentColor
|
||||
case .failed: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let journey: ConnectionJourney
|
||||
|
||||
func state(for element: Element, at index: Int) -> State {
|
||||
if case .failed = journey.phase {
|
||||
// Include the failure itself and any later progress made by SSH bootstrap.
|
||||
let completedIndex = max(journey.completedEndpointIndex, journey.completedCheckpoints.keys.max() ?? 0)
|
||||
let reachedIndex = max(journey.activeEndpointIndex, completedIndex)
|
||||
return index <= reachedIndex ? .failed : .pending
|
||||
}
|
||||
if let recovery = journey.transportRecovery {
|
||||
switch element {
|
||||
case .endpoint:
|
||||
if index == journey.activeEndpointIndex { return .active }
|
||||
return index == 0 ? .complete : .pending
|
||||
case .segment:
|
||||
return index == journey.activeEndpointIndex && recovery == .recovering ? .active : .pending
|
||||
}
|
||||
}
|
||||
if index <= journey.completedEndpointIndex { return .complete }
|
||||
if element == .segment, index == journey.activeEndpointIndex { return .active }
|
||||
return .pending
|
||||
}
|
||||
|
||||
var activityEndpointIndex: Int? {
|
||||
guard journey.phase == .connecting, journey.checkpoint == nil,
|
||||
let lastIndex = journey.endpoints.indices.last
|
||||
else { return nil }
|
||||
if let recovery = journey.transportRecovery {
|
||||
return recovery == .recovering ? lastIndex : nil
|
||||
}
|
||||
let candidate =
|
||||
journey.activeEndpointIndex > journey.completedEndpointIndex
|
||||
? journey.activeEndpointIndex - 1 : journey.completedEndpointIndex
|
||||
return min(max(journey.endpoints.startIndex, candidate), lastIndex)
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,13 @@ public struct ConnectionJourneyRouteView: View {
|
||||
let gapBetweenEndpointFrames = endpointSpacing - labelWidth
|
||||
let routeWidth = labelWidth + CGFloat(max(0, journey.endpoints.count - 1)) * endpointSpacing
|
||||
let canvasWidth = max(viewportWidth, routeWidth + 58)
|
||||
let style = ConnectionJourneyRouteStyle(journey: journey)
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(journey.endpoints.indices.dropFirst(), id: \.self) { index in
|
||||
JourneySegmentView(
|
||||
state: segmentState(for: index),
|
||||
state: style.state(for: .segment, at: index),
|
||||
checkpoint: journey.checkpoint?.endpointIndex == index
|
||||
? journey.checkpoint?.kind : journey.completedCheckpoints[index],
|
||||
checkpointIsWaiting: journey.checkpoint?.endpointIndex == index,
|
||||
@@ -67,8 +68,8 @@ public struct ConnectionJourneyRouteView: View {
|
||||
JourneyEndpointView(
|
||||
endpoint: endpoint,
|
||||
purpose: journey.purpose,
|
||||
state: endpointState(for: index),
|
||||
showsActivity: activityEndpointIndex == index,
|
||||
state: style.state(for: .endpoint, at: index),
|
||||
showsActivity: style.activityEndpointIndex == index,
|
||||
localRequirement: journey.localRequirementEndpointIndex == index
|
||||
? journey.localRequirement : nil,
|
||||
reduceMotion: reduceMotion
|
||||
@@ -82,45 +83,6 @@ public struct ConnectionJourneyRouteView: View {
|
||||
.frame(width: canvasWidth, height: 92, alignment: .center)
|
||||
}
|
||||
|
||||
private var activityEndpointIndex: Int? {
|
||||
guard journey.phase == .connecting, journey.checkpoint == nil,
|
||||
let lastIndex = journey.endpoints.indices.last
|
||||
else { return nil }
|
||||
if let recovery = journey.transportRecovery {
|
||||
return recovery == .recovering ? lastIndex : nil
|
||||
}
|
||||
let candidate: Int
|
||||
if journey.activeEndpointIndex > journey.completedEndpointIndex {
|
||||
candidate = journey.activeEndpointIndex - 1
|
||||
} else {
|
||||
candidate = journey.completedEndpointIndex
|
||||
}
|
||||
return min(max(journey.endpoints.startIndex, candidate), lastIndex)
|
||||
}
|
||||
|
||||
private func endpointState(for index: Int) -> JourneyVisualState {
|
||||
if let recovery = journey.transportRecovery {
|
||||
if index == journey.activeEndpointIndex {
|
||||
return recovery == .failed ? .failed : .active
|
||||
}
|
||||
return index == 0 ? .complete : .pending
|
||||
}
|
||||
if case .failed = journey.phase, index <= journey.activeEndpointIndex { return .failed }
|
||||
if index <= journey.completedEndpointIndex { return .complete }
|
||||
return .pending
|
||||
}
|
||||
|
||||
private func segmentState(for targetIndex: Int) -> JourneyVisualState {
|
||||
if let recovery = journey.transportRecovery {
|
||||
guard targetIndex == journey.activeEndpointIndex else { return .pending }
|
||||
return recovery == .failed ? .failed : recovery == .recovering ? .active : .pending
|
||||
}
|
||||
if case .failed = journey.phase, targetIndex <= journey.activeEndpointIndex { return .failed }
|
||||
if targetIndex <= journey.completedEndpointIndex { return .complete }
|
||||
if targetIndex == journey.activeEndpointIndex { return .active }
|
||||
return .pending
|
||||
}
|
||||
|
||||
private func scrollToActivity(_ proxy: ScrollViewProxy) {
|
||||
guard !reduceMotion, let lastIndex = journey.endpoints.indices.last else { return }
|
||||
let candidate =
|
||||
@@ -135,7 +97,6 @@ public struct ConnectionJourneyRouteView: View {
|
||||
proxy.scrollTo(target, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@@ -174,25 +135,10 @@ public struct ConnectionJourneyRouteView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
private enum JourneyVisualState {
|
||||
case pending
|
||||
case active
|
||||
case complete
|
||||
case failed
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .pending: .secondary.opacity(0.55)
|
||||
case .active, .complete: Color.accentColor
|
||||
case .failed: Color.red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct JourneyEndpointView: View {
|
||||
let endpoint: ConnectionEndpoint
|
||||
let purpose: ConnectionPurpose
|
||||
let state: JourneyVisualState
|
||||
let state: ConnectionJourneyRouteStyle.State
|
||||
let showsActivity: Bool
|
||||
let localRequirement: ConnectionJourney.LocalRequirement?
|
||||
let reduceMotion: Bool
|
||||
@@ -201,7 +147,7 @@ private struct JourneyEndpointView: View {
|
||||
ZStack(alignment: .top) {
|
||||
ZStack {
|
||||
if showsActivity {
|
||||
JourneyActivityRing(size: 52, reduceMotion: reduceMotion)
|
||||
JourneyActivityRing(size: 52, color: state.color, reduceMotion: reduceMotion)
|
||||
}
|
||||
Circle()
|
||||
.fill(.background)
|
||||
@@ -260,7 +206,7 @@ private struct JourneyEndpointView: View {
|
||||
}
|
||||
|
||||
private struct JourneySegmentView: View {
|
||||
let state: JourneyVisualState
|
||||
let state: ConnectionJourneyRouteStyle.State
|
||||
let checkpoint: ConnectionJourney.CheckpointKind?
|
||||
let checkpointIsWaiting: Bool
|
||||
let reduceMotion: Bool
|
||||
@@ -275,13 +221,13 @@ private struct JourneySegmentView: View {
|
||||
case .active:
|
||||
if checkpointIsWaiting {
|
||||
HStack(spacing: 0) {
|
||||
Rectangle().fill(Color.accentColor)
|
||||
Rectangle().fill(state.color)
|
||||
.frame(width: geometry.size.width / 2, height: 4)
|
||||
Color.clear
|
||||
.frame(width: geometry.size.width / 2, height: 4)
|
||||
}
|
||||
} else {
|
||||
JourneyTravelingLine(reduceMotion: reduceMotion)
|
||||
JourneyTravelingLine(color: state.color, reduceMotion: reduceMotion)
|
||||
}
|
||||
case .pending:
|
||||
EmptyView()
|
||||
@@ -290,9 +236,9 @@ private struct JourneySegmentView: View {
|
||||
if let checkpoint {
|
||||
ZStack {
|
||||
if checkpointIsWaiting {
|
||||
JourneyActivityRing(size: 42, reduceMotion: reduceMotion)
|
||||
JourneyActivityRing(size: 42, color: state.color, reduceMotion: reduceMotion)
|
||||
}
|
||||
Circle().fill(Color.accentColor).frame(width: 28, height: 28)
|
||||
Circle().fill(state.color).frame(width: 28, height: 28)
|
||||
Image(systemName: checkpoint == .authentication ? "key.fill" : "checkmark.shield.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Color.white)
|
||||
@@ -311,12 +257,13 @@ private struct JourneySegmentView: View {
|
||||
}
|
||||
|
||||
private struct JourneyTravelingLine: View {
|
||||
let color: Color
|
||||
let reduceMotion: Bool
|
||||
|
||||
var body: some View {
|
||||
if reduceMotion {
|
||||
HStack(spacing: 0) {
|
||||
Rectangle().fill(Color.accentColor).frame(width: 34, height: 4)
|
||||
Rectangle().fill(color).frame(width: 34, height: 4)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
} else {
|
||||
@@ -328,8 +275,8 @@ private struct JourneyTravelingLine: View {
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor,
|
||||
Color.accentColor.opacity(0.55),
|
||||
color,
|
||||
color.opacity(0.55),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
@@ -345,11 +292,12 @@ private struct JourneyTravelingLine: View {
|
||||
|
||||
private struct JourneyActivityRing: View {
|
||||
let size: CGFloat
|
||||
let color: Color
|
||||
let reduceMotion: Bool
|
||||
|
||||
var body: some View {
|
||||
if reduceMotion {
|
||||
Circle().stroke(Color.accentColor.opacity(0.45), lineWidth: 4)
|
||||
Circle().stroke(color.opacity(0.45), lineWidth: 4)
|
||||
.frame(width: size, height: size)
|
||||
} else {
|
||||
TimelineView(.animation) { timeline in
|
||||
@@ -361,9 +309,9 @@ private struct JourneyActivityRing: View {
|
||||
.stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(0.15),
|
||||
Color.accentColor,
|
||||
Color.accentColor.opacity(0.32),
|
||||
color.opacity(0.15),
|
||||
color,
|
||||
color.opacity(0.32),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ actor EternalTerminalEngine: ResumableTerminalEngine {
|
||||
private var bridge: TerminalTCPBridge?
|
||||
private var reader: Task<Void, Never>?
|
||||
private var observer: Task<Void, Never>?
|
||||
private var connectionState: ETConnectionState = .idle
|
||||
private var closed = false
|
||||
|
||||
init(receive: @escaping EventHandler) {
|
||||
@@ -106,6 +107,7 @@ actor EternalTerminalEngine: ResumableTerminalEngine {
|
||||
|
||||
private func receiveState(_ state: ETConnectionState) async {
|
||||
guard !closed else { return }
|
||||
connectionState = state
|
||||
switch state {
|
||||
case .connected: await receive(.connected)
|
||||
case .reconnecting, .disconnected: await receive(.recovering)
|
||||
@@ -144,6 +146,9 @@ actor EternalTerminalEngine: ResumableTerminalEngine {
|
||||
func resume() async throws {
|
||||
try checkOpen()
|
||||
await session?.resumeFromApplicationBackground()
|
||||
try checkOpen()
|
||||
// ET keeps a live connection across suspension and does not repeat its connected event on resume.
|
||||
if connectionState == .connected { await receive(.connected) }
|
||||
}
|
||||
|
||||
func prepareRetry() async {
|
||||
|
||||
@@ -2033,7 +2033,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
CURRENT_PROJECT_VERSION = 90;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Development;
|
||||
@@ -2162,7 +2162,7 @@
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
CURRENT_PROJECT_VERSION = 90;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TFIWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces";
|
||||
@@ -2345,7 +2345,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
CURRENT_PROJECT_VERSION = 90;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Production;
|
||||
@@ -2384,7 +2384,7 @@
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
CURRENT_PROJECT_VERSION = 90;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TFIWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces";
|
||||
@@ -2481,7 +2481,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 63;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = F6SUNDBBN9;
|
||||
@@ -2551,7 +2551,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 63;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = F6SUNDBBN9;
|
||||
@@ -2597,7 +2597,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
@@ -2635,7 +2635,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
|
||||
Reference in new issue
Block a user