fix: Eternal Terminal reconnecting

This commit is contained in:
Kaede Akino committed 2026-10-17 19:40:59 +08:00
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 {
+8 -8
View File
@@ -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 = "";