From 7d4a73decc0387e773badc37a0fc150b23dcd4de Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 19:40:59 +0800 Subject: [PATCH] fix: Eternal Terminal reconnecting --- .../ConnectionJourneyRouteStyleTests.swift | 191 ++++++++++++++++++ .../EternalTerminalResumeTests.swift | 127 ++++++++++++ .../ConnectionJourneyRouteStyle.swift | 60 ++++++ .../ConnectionJourneyRouteView.swift | 92 ++------- .../SSH/EternalTerminalEngine.swift | 5 + Termind.xcodeproj/project.pbxproj | 16 +- 6 files changed, 411 insertions(+), 80 deletions(-) create mode 100644 FrameworkTests/ConnectionJourneyRouteStyleTests.swift create mode 100644 FrameworkTests/EternalTerminalResumeTests.swift create mode 100644 Frameworks/TermindPresentation/ConnectionJourneyRouteStyle.swift diff --git a/FrameworkTests/ConnectionJourneyRouteStyleTests.swift b/FrameworkTests/ConnectionJourneyRouteStyleTests.swift new file mode 100644 index 00000000..01d145d4 --- /dev/null +++ b/FrameworkTests/ConnectionJourneyRouteStyleTests.swift @@ -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) + } + } + } + } +} diff --git a/FrameworkTests/EternalTerminalResumeTests.swift b/FrameworkTests/EternalTerminalResumeTests.swift new file mode 100644 index 00000000..1b920956 --- /dev/null +++ b/FrameworkTests/EternalTerminalResumeTests.swift @@ -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.makeStream() + let states = AsyncStream.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 } + } +} diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteStyle.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteStyle.swift new file mode 100644 index 00000000..8a4e0dbc --- /dev/null +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteStyle.swift @@ -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) + } +} diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift index 0b9e9045..66a480ae 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift @@ -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 ), diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/EternalTerminalEngine.swift b/Frameworks/TermindRemoteInfrastructure/SSH/EternalTerminalEngine.swift index f3fa6152..2c179afd 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/EternalTerminalEngine.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/EternalTerminalEngine.swift @@ -28,6 +28,7 @@ actor EternalTerminalEngine: ResumableTerminalEngine { private var bridge: TerminalTCPBridge? private var reader: Task? private var observer: Task? + 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 { diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 7e0c1eee..1ff4b1e8 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -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 = "";