Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.9
// swift-tools-version:6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand All @@ -16,20 +16,32 @@ let package = Package(
name: "Atlantis",
targets: ["Atlantis"]),
],
dependencies: [
// Temporary integration source. Switch to the first official release containing
// https://github.com/grpc/grpc-swift-2/pull/51 before tagging Atlantis 2.0.
.package(url: "https://github.com/NghiaTranUIT/grpc-swift-2.git",
branch: "codex/client-diagnostics-observer")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "Atlantis",
dependencies: [],
dependencies: [
.product(name: "GRPCCore", package: "grpc-swift-2")
],
path: "Sources",
resources: [.copy("PrivacyInfo.xcprivacy")])
,
.testTarget(
name: "AtlantisTests",
dependencies: ["Atlantis"],
dependencies: [
"Atlantis",
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCInProcessTransport", package: "grpc-swift-2")
],
path: "Tests/atlantisTests",
resources: [.process("Resources/sse-server.js")])
],
swiftLanguageVersions: [.v5]
swiftLanguageModes: [.v5]
)
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
- [x] ✅ **Automatically** intercept all YOUR HTTP/HTTPS Traffic with 1 click
- [x] ✅ **No Proxy or trust any Certificates**
- [x] ✅ Capture WS/WSS Traffic from URLSessionWebSocketTask
- [x] Capture gRPC traffic (Advanced)
- [x] Automatically capture gRPC-Swift 2 client traffic
- [x] Support iOS Physical Devices and Simulators, including iPhone, iPad, Apple Watch, Apple TV
- [x] **NEW:** Support Android with OkHttp, Retrofit, and Apollo
- [x] Review traffic log from macOS [Proxyman](https://proxyman.com) app ([Github](https://github.com/ProxymanApp/Proxyman))
Expand All @@ -34,8 +34,9 @@
### iOS
- macOS Proxyman app
- iOS 16.0+ / macOS 11+ / Mac Catalyst 13.0+ / tvOS 13.0+ / watchOS 10.0+
- Xcode 14+
- Swift 5.0+
- Xcode 16.3+ with the Swift 6.1 toolchain
- Automatic gRPC capture requires gRPC-Swift 2 and iOS 18+ / macOS 15+ / tvOS 18+ / watchOS 11+ / visionOS 2+
- A Proxyman build with Atlantis gRPC schema v1 support is required to display captured RPCs

### Android
- See [Atlantis Android](https://github.com/ProxymanApp/atlantis-android) for Android integration.
Expand Down Expand Up @@ -88,7 +89,7 @@ struct AtlantisSwiftUIAppApp: App {
// If you have many Macbooks on the same WiFi Network, you can specify your Macbook's name
// Find your Macbook's name by opening Proxyman App -> Certificate Menu -> Install Certificate for iOS -> With Atlantis ->
// Click on "How to start Atlantis" -> Select "SwiftUI" Tab
// Atlantis.start("Your's Macbook Pro")
// Atlantis.start(hostName: "Your's Macbook Pro")
#endif
}
}
Expand All @@ -112,7 +113,7 @@ func application(_ application: UIApplication, didFinishLaunchingWithOptions lau
// If you have many Macbooks on the same WiFi Network, you can specify your Macbook's name
// Find your Macbook's name by opening Proxyman App -> Certificate Menu -> Install Certificate for iOS -> With Atlantis ->
// Click on "How to start Atlantis" -> Select "SwiftUI" Tab
// Atlantis.start("Your's Macbook Pro")
// Atlantis.start(hostName: "Your's Macbook Pro")
#endif

return true
Expand Down Expand Up @@ -145,9 +146,24 @@ func application(_ application: UIApplication, didFinishLaunchingWithOptions lau
1. Open Proxyman for macOS
2. Make sure your iOS devices/simulator and macOS Proxyman are in the **same Wi-Fi network** or connect your iOS Devices to your Mac by a **USB cable**
3. Start your iOS app via Xcode. Works with iOS Simulator or iOS Devices.
4. Proxyman now captures all HTTP/HTTPS, Websocket from your iOS app without any configuration.
4. Proxyman now captures all HTTP/HTTPS, WebSocket, and supported gRPC traffic from your app without client configuration.
5. Enjoy debugging ❤️

## Capture gRPC-Swift 2 Traffic

Atlantis automatically observes client RPCs made by gRPC-Swift 2. Keep the normal `Atlantis.start()` call shown above and start it before the first RPC you want to inspect. The gRPC client may be constructed before Atlantis starts; no client interceptor, system proxy, certificate, transport wrapper, or channel configuration is required.

Captured data includes:

- Logical calls and individual retry or hedging attempts
- Request, response, and trailing metadata, including duplicate and binary values
- Serialized unary and streaming request/response messages
- Local and remote peers, final status, cancellation, and transport failures

Atlantis captures the logical gRPC data before transport compression and TLS. It does not produce a byte-for-byte HTTP/2 trace. Individual payloads larger than 50 MB are represented as omitted, and disconnected buffering is limited to 256 events or 64 MiB.

This integration supports gRPC-Swift 2 clients using the official NIO transports. gRPC-Swift 1, arbitrary SwiftNIO pipelines, AsyncHTTPClient, and server-side RPCs are not captured automatically.

## Capture Websocket Traffic
- By using Atlantis, Proxyman can capture Websocket from `URLSessionWebsocketTask` from iOS out of the box.
- If your app uses 3rd-party Websocket libraries (e.g. Starscream), Atlantis doesn't work because Starscream doesn't use `URLSessionWebsocketTask` under hood.
Expand Down Expand Up @@ -636,7 +652,7 @@ Atlantis supports OkHttp 4.x and 5.x. If you're using an older version, please u
## ❓ FAQ
#### 1. How does Atlantis work?

Atlantis uses [Method Swizzling](https://nshipster.com/method-swizzling/) technique to swizzle certain functions of NSURLSession that enables Atlantis to capture HTTP/HTTPS traffic on the fly.
Atlantis uses [Method Swizzling](https://nshipster.com/method-swizzling/) to capture URLSession traffic. For gRPC-Swift 2, it registers a process-wide diagnostics observer in GRPCCore and receives serialized RPC events before the NIO transport applies compression or TLS.

Then it sends to [Proxyman app](https://proxyman.com) via a local Bonjour Service for inspecting.

Expand All @@ -653,6 +669,7 @@ Atlantis and Proxyman apps do not store any of your data on any server.
#### 4. What kind of data does Atlantis capture?

- All HTTP/HTTPS traffic from your iOS apps, that integrate the Atlantis framework
- Supported gRPC client metadata, serialized messages, statuses, errors, and retry/hedging identifiers
- Your iOS app name, bundle identifier, and small size of the logo
- iOS devices/simulators name and device models.

Expand All @@ -678,4 +695,3 @@ Atlantis is built for inspecting the Network, not debugging purposes. If you wou

## License
Atlantis is released under the Apache-2.0 License. See LICENSE for details.

12 changes: 11 additions & 1 deletion Sources/Atlantis.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public final class Atlantis: NSObject {
/// Build version of Atlantis
/// It's essential for Proxyman to known if it's compatible with this version
/// Instead of receving the number from the info.plist, we should hardcode here because the info file doesn't exist in SPM
public static let buildVersion: String = "1.36.0"
public static let buildVersion: String = "2.0.0"

/// Start Swizzle all network functions and monitoring the traffic
/// It also starts looking Bonjour network from Proxyman app.
Expand Down Expand Up @@ -117,6 +117,9 @@ public final class Atlantis: NSObject {

// Start transport layer if need
if Atlantis.shared.isEnabledTransportLayer {
GRPCNetworkInjectionController.start { [weak atlantis = Atlantis.shared] package in
atlantis?.sendGRPCEvent(package)
}
Atlantis.shared.transporter.start(configuration)
}
}
Expand All @@ -125,6 +128,7 @@ public final class Atlantis: NSObject {
@objc public class func stop() {
guard isEnabled.value else { return }
isEnabled.mutate { $0 = false }
GRPCNetworkInjectionController.stop()
if Atlantis.shared.isEnabledTransportLayer {
Atlantis.shared.transporter.stop()
}
Expand Down Expand Up @@ -168,6 +172,12 @@ private var retainedTestTransporters: [Transporter] = []

extension Atlantis {

private func sendGRPCEvent(_ package: GRPCEventPackage) {
guard Atlantis.isEnabled.value, isEnabledTransportLayer else { return }
let messageID = package.attemptID ?? package.callID
transporter.send(package: Message.buildGRPCMessage(id: messageID, item: package))
}

private func safetyCheck() {
if Atlantis.isServiceAvailable {
print("---------------------------------------------------------------------------------")
Expand Down
156 changes: 156 additions & 0 deletions Sources/GRPCEventPackage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//
// GRPCEventPackage.swift
// Atlantis
//
// Created by Proxyman on 7/13/26.
//

import Foundation

struct GRPCEventPackage: Codable, Serializable {

enum EventType: String, Codable {
case callStarted
case attemptStarted
case streamCreated
case requestMetadata
case requestMessage
case requestFinished
case responseMetadata
case responseMessage
case responseStatus
case attemptFinished
case callFinished
}

enum Outcome: String, Codable {
case completed
case status
case failed
case cancelled
}

enum RPCType: String, Codable {
case unary
case clientStreaming
case serverStreaming
case bidirectionalStreaming
case unknown
}

enum PayloadOmissionReason: String, Codable {
case exceedsSizeLimit
case bufferLimit
}

enum Direction: String, Codable {
case outbound
case inbound
}

struct MetadataEntry: Codable {
let key: String
let stringValue: String?
let binaryValue: Data?

init(key: String, stringValue: String) {
self.key = key
self.stringValue = stringValue
self.binaryValue = nil
}

init(key: String, binaryValue: [UInt8]) {
self.key = key
self.stringValue = nil
self.binaryValue = Data(binaryValue)
}
}

static let schemaVersion = 1

let eventID: String
let version: Int
let timestamp: TimeInterval
let eventType: EventType
let callID: String
let attemptID: String?
let attemptNumber: Int?
let method: String?
let rpcType: RPCType?
let remotePeer: String?
let localPeer: String?
let metadata: [MetadataEntry]?
let direction: Direction?
let sequenceNumber: Int?
private(set) var payload: Data?
let payloadSize: Int?
private(set) var payloadOmissionReason: PayloadOmissionReason?
let statusCode: Int?
let statusMessage: String?
let errorCode: Int?
let errorMessage: String?
let outcome: Outcome?

init(eventType: EventType,
callID: String,
attemptID: String? = nil,
attemptNumber: Int? = nil,
method: String? = nil,
rpcType: RPCType? = nil,
remotePeer: String? = nil,
localPeer: String? = nil,
metadata: [MetadataEntry]? = nil,
direction: Direction? = nil,
sequenceNumber: Int? = nil,
payload: Data? = nil,
payloadSize: Int? = nil,
payloadOmissionReason: PayloadOmissionReason? = nil,
statusCode: Int? = nil,
statusMessage: String? = nil,
errorCode: Int? = nil,
errorMessage: String? = nil,
outcome: Outcome? = nil) {
self.eventID = UUID().uuidString
self.version = Self.schemaVersion
self.timestamp = Date().timeIntervalSince1970
self.eventType = eventType
self.callID = callID
self.attemptID = attemptID
self.attemptNumber = attemptNumber
self.method = method
self.rpcType = rpcType
self.remotePeer = remotePeer
self.localPeer = localPeer
self.metadata = metadata
self.direction = direction
self.sequenceNumber = sequenceNumber
self.payload = payload
self.payloadSize = payloadSize
self.payloadOmissionReason = payloadOmissionReason
self.statusCode = statusCode
self.statusMessage = statusMessage
self.errorCode = errorCode
self.errorMessage = errorMessage
self.outcome = outcome
}

var containsPayload: Bool {
return payload != nil
}

func omittingPayloadBecauseBufferIsFull() -> GRPCEventPackage {
var copy = self
copy.payload = nil
copy.payloadOmissionReason = .bufferLimit
return copy
}

func toData() -> Data? {
do {
return try JSONEncoder().encode(self)
} catch {
print("[Atlantis][gRPC] Could not encode diagnostics event: \(error)")
return nil
}
}
}
Loading
Loading