From 49926c73042d697f0684f7eafbbd98cad8ad7245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?enes=20o=CC=88ztu=CC=88rk?= Date: Sun, 4 Jan 2026 23:14:55 +0300 Subject: [PATCH 1/3] feat(network): Add network request export with multiple formats and filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive network request export functionality to Network History: Export Formats: - HAR (HTTP Archive 1.2) - Browser DevTools compatible - Postman Collection v2.1 - Direct import to Postman - Swagger/OpenAPI 3.0.3 - API documentation generation - Curl Scripts ZIP - Individual .sh files per request with README - Raw Text - Human readable format Export Filters (Settings → Export Filters): - Exclude Images (jpg, png, gif, webp, svg, ico, heic) - Exclude Analytics SDKs (80+ providers: Adjust, AppsFlyer, Facebook, Amplitude, Mixpanel, Segment, Branch, Criteo, Crashlytics, etc.) - Exclude Firebase Analytics (keeps Remote Config) UX Improvements: - HUD overlay with spinner during export - Background thread execution (non-blocking UI) - User-friendly error messages New files: - FLEXNetworkExporter.h/m Modified files: - FLEXNetworkMITMViewController.m - FLEXNetworkSettingsController.m - NSUserDefaults+FLEX.h/m --- Classes/Network/FLEXNetworkExporter.h | 79 ++ Classes/Network/FLEXNetworkExporter.m | 856 ++++++++++++++++++ .../Network/FLEXNetworkMITMViewController.m | 650 +++++++++---- .../Network/FLEXNetworkSettingsController.m | 260 ++++-- .../Utility/Categories/NSUserDefaults+FLEX.h | 30 +- .../Utility/Categories/NSUserDefaults+FLEX.m | 191 ++-- FLEX.xcodeproj/project.pbxproj | 8 + 7 files changed, 1711 insertions(+), 363 deletions(-) create mode 100644 Classes/Network/FLEXNetworkExporter.h create mode 100644 Classes/Network/FLEXNetworkExporter.m diff --git a/Classes/Network/FLEXNetworkExporter.h b/Classes/Network/FLEXNetworkExporter.h new file mode 100644 index 0000000000..07fa0bfdb8 --- /dev/null +++ b/Classes/Network/FLEXNetworkExporter.h @@ -0,0 +1,79 @@ +// +// FLEXNetworkExporter.h +// FLEX +// +// Created by Enes OZTURK on 4/1/26. +// + +#import + +@class FLEXHTTPTransaction; + +NS_ASSUME_NONNULL_BEGIN + +/// Export formats for network requests +typedef NS_ENUM(NSUInteger, FLEXNetworkExportFormat) { + FLEXNetworkExportFormatRequestOnly, + FLEXNetworkExportFormatResponseOnly, + FLEXNetworkExportFormatRaw, + FLEXNetworkExportFormatHAR, + FLEXNetworkExportFormatPostman, + FLEXNetworkExportFormatSwagger, + FLEXNetworkExportFormatCurlZip, +}; + +/// A helper class for exporting network transactions in various formats. +@interface FLEXNetworkExporter : NSObject + +#pragma mark - Single Transaction Export + +/// Export a single transaction as request-only text ++ (NSString *)requestStringForTransaction:(FLEXHTTPTransaction *)transaction; + +/// Export a single transaction as response-only text ++ (NSString *)responseStringForTransaction:(FLEXHTTPTransaction *)transaction; + +/// Export a single transaction as raw text (request + response) ++ (NSString *)rawStringForTransaction:(FLEXHTTPTransaction *)transaction; + +/// Export a single transaction as HAR entry dictionary ++ (NSDictionary *)harEntryForTransaction:(FLEXHTTPTransaction *)transaction; + +#pragma mark - Multiple Transactions Export + +/// Export multiple transactions as raw text ++ (NSString *)rawStringForTransactions:(NSArray *)transactions; + +/// Export multiple transactions as HAR file dictionary ++ (NSDictionary *)harFileForTransactions:(NSArray *)transactions; + +/// Export multiple transactions as HAR JSON string ++ (NSString *)harJSONStringForTransactions:(NSArray *)transactions; + +/// Export multiple transactions as Postman Collection v2.1 JSON string ++ (NSString *)postmanCollectionForTransactions:(NSArray *)transactions; + +/// Export multiple transactions as Swagger/OpenAPI 3.0 JSON string ++ (NSString *)swaggerSpecForTransactions:(NSArray *)transactions; + +/// Export multiple transactions as curl commands in a ZIP file, returns file URL ++ (nullable NSURL *)curlZipForTransactions:(NSArray *)transactions; + +#pragma mark - Filtering + +/// Filter transactions based on user settings (images, analytics, Firebase) ++ (NSArray *)filterTransactionsForExport:(NSArray *)transactions; + +#pragma mark - File Operations + +/// Save content to a temporary file and return the file URL ++ (nullable NSURL *)saveToTemporaryFile:(NSString *)content + withFilename:(NSString *)filename; + +/// Get a suggested filename for the export ++ (NSString *)suggestedFilenameForFormat:(FLEXNetworkExportFormat)format + isMultiple:(BOOL)isMultiple; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/Network/FLEXNetworkExporter.m b/Classes/Network/FLEXNetworkExporter.m new file mode 100644 index 0000000000..23cd647608 --- /dev/null +++ b/Classes/Network/FLEXNetworkExporter.m @@ -0,0 +1,856 @@ +// +// FLEXNetworkExporter.m +// FLEX +// +// Created by Enes OZTURK on 4/1/26. +// + +#import "FLEXNetworkExporter.h" +#import "FLEXNetworkCurlLogger.h" +#import "FLEXNetworkRecorder.h" +#import "FLEXNetworkTransaction.h" +#import "FLEXUtility.h" +#import "NSDateFormatter+FLEX.h" +#import "NSUserDefaults+FLEX.h" + +@implementation FLEXNetworkExporter + +#pragma mark - Single Transaction Export + ++ (NSString *)requestStringForTransaction:(FLEXHTTPTransaction *)transaction +{ + NSMutableString *output = [NSMutableString new]; + + NSURLRequest *request = transaction.request; + + // Request Line + [output appendFormat:@"%@ %@ HTTP/1.1\n", request.HTTPMethod ?: @"GET", request.URL.absoluteString]; + + // Headers + [output appendString:@"\n--- Request Headers ---\n"]; + NSDictionary *headers = request.allHTTPHeaderFields; + for (NSString *key in [headers.allKeys sortedArrayUsingSelector:@selector(caseInsensitiveCompare:)]) { + [output appendFormat:@"%@: %@\n", key, headers[key]]; + } + + // Body + NSData *bodyData = transaction.cachedRequestBody; + if (bodyData.length > 0) { + [output appendString:@"\n--- Request Body ---\n"]; + NSString *bodyString = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding]; + if (bodyString) { + // Try to pretty print JSON + if ([FLEXUtility isValidJSONData:bodyData]) { + NSString *prettyJSON = [FLEXUtility prettyJSONStringFromData:bodyData]; + [output appendString:prettyJSON ?: bodyString]; + } else { + [output appendString:bodyString]; + } + } else { + [output appendFormat:@"[Binary data: %@ bytes]", @(bodyData.length)]; + } + [output appendString:@"\n"]; + } + + return output; +} + ++ (NSString *)responseStringForTransaction:(FLEXHTTPTransaction *)transaction +{ + NSMutableString *output = [NSMutableString new]; + + NSHTTPURLResponse *response = (NSHTTPURLResponse *)transaction.response; + + // Status Line + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + [output appendFormat:@"HTTP/1.1 %ld %@\n", + (long)response.statusCode, + [NSHTTPURLResponse localizedStringForStatusCode:response.statusCode]]; + + // Response Headers + [output appendString:@"\n--- Response Headers ---\n"]; + NSDictionary *headers = response.allHeaderFields; + for (NSString *key in [headers.allKeys sortedArrayUsingSelector:@selector(caseInsensitiveCompare:)]) { + [output appendFormat:@"%@: %@\n", key, headers[key]]; + } + } + + // Response Body + NSData *responseData = [FLEXNetworkRecorder.defaultRecorder cachedResponseBodyForTransaction:transaction]; + if (responseData.length > 0) { + [output appendString:@"\n--- Response Body ---\n"]; + NSString *bodyString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; + if (bodyString) { + // Try to pretty print JSON + if ([FLEXUtility isValidJSONData:responseData]) { + NSString *prettyJSON = [FLEXUtility prettyJSONStringFromData:responseData]; + [output appendString:prettyJSON ?: bodyString]; + } else { + [output appendString:bodyString]; + } + } else { + [output appendFormat:@"[Binary data: %@ bytes]", @(responseData.length)]; + } + [output appendString:@"\n"]; + } else if (transaction.receivedDataLength > 0) { + [output appendString:@"\n--- Response Body ---\n"]; + [output appendFormat:@"[Response not in cache: %lld bytes received]", transaction.receivedDataLength]; + [output appendString:@"\n"]; + } + + // Error if any + if (transaction.error) { + [output appendString:@"\n--- Error ---\n"]; + [output appendFormat:@"%@\n", transaction.error.localizedDescription]; + } + + return output; +} + ++ (NSString *)rawStringForTransaction:(FLEXHTTPTransaction *)transaction +{ + NSMutableString *output = [NSMutableString new]; + + // Metadata + [output appendString:@"================================================================================\n"]; + [output appendFormat:@"URL: %@\n", transaction.request.URL.absoluteString]; + [output appendFormat:@"Start Time: %@\n", [NSDateFormatter flex_stringFrom:transaction.startTime format:FLEXDateFormatVerbose]]; + [output appendFormat:@"Duration: %.3f ms\n", transaction.duration * 1000]; + [output appendFormat:@"Latency: %.3f ms\n", transaction.latency * 1000]; + [output appendString:@"================================================================================\n\n"]; + + // Request + [output appendString:@">>> REQUEST >>>\n\n"]; + [output appendString:[self requestStringForTransaction:transaction]]; + + // Response + [output appendString:@"\n<<< RESPONSE <<<\n\n"]; + [output appendString:[self responseStringForTransaction:transaction]]; + + return output; +} + ++ (NSDictionary *)harEntryForTransaction:(FLEXHTTPTransaction *)transaction +{ + NSURLRequest *request = transaction.request; + NSHTTPURLResponse *response = (NSHTTPURLResponse *)transaction.response; + + // Format the start time as ISO 8601 + NSDateFormatter *isoFormatter = [[NSDateFormatter alloc] init]; + isoFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; + isoFormatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"]; + NSString *startedDateTime = [isoFormatter stringFromDate:transaction.startTime] ?: @""; + + // Build request headers array + NSMutableArray *requestHeaders = [NSMutableArray new]; + [request.allHTTPHeaderFields enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL *stop) { + [requestHeaders addObject:@ {@"name" : key, @"value" : value}]; + }]; + + // Build query string array + NSMutableArray *queryString = [NSMutableArray new]; + NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:request.URL resolvingAgainstBaseURL:NO]; + for (NSURLQueryItem *item in urlComponents.queryItems) { + [queryString addObject:@{@"name" : item.name ?: @"", @"value" : item.value ?: @""}]; + } + + // Build post data if present + NSMutableDictionary *postData = nil; + NSData *bodyData = transaction.cachedRequestBody; + if (bodyData.length > 0) { + postData = [NSMutableDictionary new]; + NSString *mimeType = [request valueForHTTPHeaderField:@"Content-Type"] ?: @"application/octet-stream"; + postData[@"mimeType"] = mimeType; + + NSString *bodyString = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding]; + if (bodyString) { + postData[@"text"] = bodyString; + } else { + postData[@"text"] = [bodyData base64EncodedStringWithOptions:0]; + postData[@"encoding"] = @"base64"; + } + } + + // Build response headers array + NSMutableArray *responseHeaders = [NSMutableArray new]; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + [response.allHeaderFields enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL *stop) { + [responseHeaders addObject:@ {@"name" : key, @"value" : value}]; + }]; + } + + // Build response content + NSMutableDictionary *content = [NSMutableDictionary new]; + content[@"size"] = @(transaction.receivedDataLength); + content[@"mimeType"] = response.MIMEType ?: @"application/octet-stream"; + + NSData *responseData = [FLEXNetworkRecorder.defaultRecorder cachedResponseBodyForTransaction:transaction]; + if (responseData.length > 0) { + NSString *responseString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; + if (responseString) { + content[@"text"] = responseString; + } else { + content[@"text"] = [responseData base64EncodedStringWithOptions:0]; + content[@"encoding"] = @"base64"; + } + } + + // Build the HAR entry + NSMutableDictionary *entry = [NSMutableDictionary new]; + entry[@"startedDateTime"] = startedDateTime; + entry[@"time"] = @(transaction.duration * 1000); // Convert to milliseconds + + // Request object + NSMutableDictionary *requestObj = [NSMutableDictionary new]; + requestObj[@"method"] = request.HTTPMethod ?: @"GET"; + requestObj[@"url"] = request.URL.absoluteString ?: @""; + requestObj[@"httpVersion"] = @"HTTP/1.1"; + requestObj[@"cookies"] = @[]; + requestObj[@"headers"] = requestHeaders; + requestObj[@"queryString"] = queryString; + requestObj[@"headersSize"] = @(-1); + requestObj[@"bodySize"] = @(bodyData.length); + if (postData) { + requestObj[@"postData"] = postData; + } + entry[@"request"] = requestObj; + + // Response object + NSMutableDictionary *responseObj = [NSMutableDictionary new]; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + responseObj[@"status"] = @(response.statusCode); + responseObj[@"statusText"] = [NSHTTPURLResponse localizedStringForStatusCode:response.statusCode]; + } else { + responseObj[@"status"] = @(0); + responseObj[@"statusText"] = @""; + } + responseObj[@"httpVersion"] = @"HTTP/1.1"; + responseObj[@"cookies"] = @[]; + responseObj[@"headers"] = responseHeaders; + responseObj[@"content"] = content; + responseObj[@"redirectURL"] = @""; + responseObj[@"headersSize"] = @(-1); + responseObj[@"bodySize"] = @(transaction.receivedDataLength); + entry[@"response"] = responseObj; + + // Timings + entry[@"timings"] = @{ + @"blocked" : @(-1), + @"dns" : @(-1), + @"connect" : @(-1), + @"send" : @(0), + @"wait" : @(transaction.latency * 1000), + @"receive" : @((transaction.duration - transaction.latency) * 1000), + @"ssl" : @(-1) + }; + + entry[@"cache"] = @{}; + + return entry; +} + +#pragma mark - Multiple Transactions Export + ++ (NSString *)rawStringForTransactions:(NSArray *)transactions +{ + NSMutableString *output = [NSMutableString new]; + + [output appendFormat:@"FLEX Network Export - %lu requests\n", (unsigned long)transactions.count]; + [output appendFormat:@"Exported at: %@\n", [NSDateFormatter flex_stringFrom:[NSDate date] format:FLEXDateFormatVerbose]]; + [output appendString:@"\n"]; + + for (NSUInteger i = 0; i < transactions.count; i++) { + [output appendFormat:@"\n#%lu of %lu\n", (unsigned long)(i + 1), (unsigned long)transactions.count]; + [output appendString:[self rawStringForTransaction:transactions[i]]]; + [output appendString:@"\n"]; + } + + return output; +} + ++ (NSDictionary *)harFileForTransactions:(NSArray *)transactions +{ + NSMutableArray *entries = [NSMutableArray new]; + + for (FLEXHTTPTransaction *transaction in transactions) { + [entries addObject:[self harEntryForTransaction:transaction]]; + } + + NSDictionary *harFile = @{ + @"log" : @ { + @"version" : @"1.2", + @"creator" : @ { + @"name" : @"FLEX", + @"version" : @"5.0" + }, + @"entries" : entries + } + }; + + return harFile; +} + ++ (NSString *)harJSONStringForTransactions:(NSArray *)transactions +{ + NSDictionary *harFile = [self harFileForTransactions:transactions]; + + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:harFile + options:NSJSONWritingPrettyPrinted + error:&error]; + + if (error || !jsonData) { + return nil; + } + + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; +} + +#pragma mark - Postman Export + ++ (NSString *)postmanCollectionForTransactions:(NSArray *)transactions +{ + NSMutableArray *items = [NSMutableArray new]; + + for (FLEXHTTPTransaction *transaction in transactions) { + NSURLRequest *request = transaction.request; + + // Build URL object for Postman + NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:request.URL resolvingAgainstBaseURL:NO]; + + // Query parameters + NSMutableArray *queryParams = [NSMutableArray new]; + for (NSURLQueryItem *item in urlComponents.queryItems) { + [queryParams addObject:@{ + @"key" : item.name ?: @"", + @"value" : item.value ?: @"" + }]; + } + + // Headers + NSMutableArray *headers = [NSMutableArray new]; + [request.allHTTPHeaderFields enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL *stop) { + [headers addObject:@ { + @"key" : key, + @"value" : value + }]; + }]; + + // Build URL object + NSMutableDictionary *urlObj = [NSMutableDictionary new]; + urlObj[@"raw"] = request.URL.absoluteString ?: @""; + urlObj[@"protocol"] = urlComponents.scheme ?: @"https"; + urlObj[@"host"] = @[ urlComponents.host ?: @"" ]; + if (urlComponents.path.length > 0) { + NSArray *pathComponents = [urlComponents.path componentsSeparatedByString:@"/"]; + urlObj[@"path"] = [pathComponents filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"length > 0"]]; + } + if (queryParams.count > 0) { + urlObj[@"query"] = queryParams; + } + + // Build request object + NSMutableDictionary *requestObj = [NSMutableDictionary new]; + requestObj[@"method"] = request.HTTPMethod ?: @"GET"; + requestObj[@"header"] = headers; + requestObj[@"url"] = urlObj; + + // Body if present + NSData *bodyData = transaction.cachedRequestBody; + if (bodyData.length > 0) { + NSString *contentType = [request valueForHTTPHeaderField:@"Content-Type"] ?: @""; + NSMutableDictionary *body = [NSMutableDictionary new]; + + if ([contentType containsString:@"application/json"]) { + body[@"mode"] = @"raw"; + body[@"raw"] = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding] ?: @""; + body[@"options"] = @{@"raw" : @ {@"language" : @"json"}}; + } else if ([contentType containsString:@"x-www-form-urlencoded"]) { + body[@"mode"] = @"urlencoded"; + NSString *bodyString = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding]; + NSMutableArray *urlencoded = [NSMutableArray new]; + for (NSString *pair in [bodyString componentsSeparatedByString:@"&"]) { + NSArray *keyValue = [pair componentsSeparatedByString:@"="]; + if (keyValue.count >= 2) { + [urlencoded addObject:@{ + @"key" : [keyValue[0] stringByRemovingPercentEncoding] ?: @"", + @"value" : [keyValue[1] stringByRemovingPercentEncoding] ?: @"" + }]; + } + } + body[@"urlencoded"] = urlencoded; + } else { + body[@"mode"] = @"raw"; + body[@"raw"] = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding] ?: @""; + } + requestObj[@"body"] = body; + } + + // Build item + NSString *itemName = request.URL.lastPathComponent.length > 0 ? request.URL.lastPathComponent : request.URL.host; + NSDictionary *item = @{ + @"name" : itemName ?: @"Request", + @"request" : requestObj, + @"response" : @[] + }; + + [items addObject:item]; + } + + // Build collection + NSDictionary *collection = @{ + @"info" : @ { + @"name" : @"FLEX Network Export", + @"description" : [NSString stringWithFormat:@"Exported from FLEX on %@", [NSDate date]], + @"schema" : @"https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + @"item" : items + }; + + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:collection + options:NSJSONWritingPrettyPrinted + error:&error]; + + if (error || !jsonData) { + return nil; + } + + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; +} + +#pragma mark - Swagger/OpenAPI Export + ++ (NSString *)swaggerSpecForTransactions:(NSArray *)transactions +{ + // Group transactions by path + NSMutableDictionary *paths = [NSMutableDictionary new]; + NSMutableSet *servers = [NSMutableSet new]; + + for (FLEXHTTPTransaction *transaction in transactions) { + NSURLRequest *request = transaction.request; + NSURL *url = request.URL; + + // Collect servers + NSString *serverURL = [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; + if (url.port) { + serverURL = [serverURL stringByAppendingFormat:@":%@", url.port]; + } + [servers addObject:serverURL]; + + // Get path without query + NSString *path = url.path.length > 0 ? url.path : @"/"; + NSString *method = [request.HTTPMethod lowercaseString] ?: @"get"; + + // Build operation + NSMutableDictionary *operation = [NSMutableDictionary new]; + operation[@"summary"] = [NSString stringWithFormat:@"%@ %@", request.HTTPMethod, path]; + operation[@"operationId"] = [NSString stringWithFormat:@"%@_%@", method, + [[path stringByReplacingOccurrencesOfString:@"/" withString:@"_"] + stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"_"]]]; + + // Parameters (query string) + NSMutableArray *parameters = [NSMutableArray new]; + NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + for (NSURLQueryItem *item in urlComponents.queryItems) { + [parameters addObject:@{ + @"name" : item.name ?: @"", + @"in" : @"query", + @"schema" : @ {@"type" : @"string"}, + @"example" : item.value ?: @"" + }]; + } + if (parameters.count > 0) { + operation[@"parameters"] = parameters; + } + + // Request body + NSData *bodyData = transaction.cachedRequestBody; + if (bodyData.length > 0) { + NSString *contentType = [request valueForHTTPHeaderField:@"Content-Type"] ?: @"application/json"; + NSMutableDictionary *requestBody = [NSMutableDictionary new]; + requestBody[@"required"] = @YES; + + NSMutableDictionary *content = [NSMutableDictionary new]; + NSMutableDictionary *mediaType = [NSMutableDictionary new]; + + NSString *bodyString = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding]; + if (bodyString) { + // Try to parse as JSON for schema + NSError *jsonError = nil; + id jsonObj = [NSJSONSerialization JSONObjectWithData:bodyData options:0 error:&jsonError]; + if (!jsonError && jsonObj) { + mediaType[@"example"] = jsonObj; + } else { + mediaType[@"example"] = bodyString; + } + } + mediaType[@"schema"] = @{@"type" : @"object"}; + content[contentType] = mediaType; + requestBody[@"content"] = content; + operation[@"requestBody"] = requestBody; + } + + // Response + NSHTTPURLResponse *response = (NSHTTPURLResponse *)transaction.response; + NSMutableDictionary *responses = [NSMutableDictionary new]; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSString *statusCode = [NSString stringWithFormat:@"%ld", (long)response.statusCode]; + responses[statusCode] = @{ + @"description" : [NSHTTPURLResponse localizedStringForStatusCode:response.statusCode] + }; + } else { + responses[@"200"] = @{@"description" : @"Successful response"}; + } + operation[@"responses"] = responses; + + // Add to paths + if (!paths[path]) { + paths[path] = [NSMutableDictionary new]; + } + paths[path][method] = operation; + } + + // Build servers array + NSMutableArray *serversArray = [NSMutableArray new]; + for (NSString *server in servers) { + [serversArray addObject:@{@"url" : server}]; + } + + // Build OpenAPI spec + NSDictionary *spec = @{ + @"openapi" : @"3.0.3", + @"info" : @ { + @"title" : @"FLEX Network Export", + @"description" : @"API documentation generated from FLEX network capture", + @"version" : @"1.0.0" + }, + @"servers" : serversArray.count > 0 ? serversArray : @[ @{@"url" : @"https://api.example.com"} ], + @"paths" : paths + }; + + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:spec + options:NSJSONWritingPrettyPrinted + error:&error]; + + if (error || !jsonData) { + return nil; + } + + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; +} + +#pragma mark - Curl ZIP Export + ++ (NSURL *)curlZipForTransactions:(NSArray *)transactions +{ + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + formatter.dateFormat = @"yyyyMMdd_HHmmss"; + NSString *timestamp = [formatter stringFromDate:[NSDate date]]; + + NSString *tempDir = NSTemporaryDirectory(); + NSString *curlFolderName = [NSString stringWithFormat:@"curl_requests_%@", timestamp]; + NSString *curlFolderPath = [tempDir stringByAppendingPathComponent:curlFolderName]; + NSString *zipPath = [tempDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.zip", curlFolderName]]; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *error = nil; + + // Remove existing folder/zip if exists + [fileManager removeItemAtPath:curlFolderPath error:nil]; + [fileManager removeItemAtPath:zipPath error:nil]; + + // Create folder for curl files + if (![fileManager createDirectoryAtPath:curlFolderPath withIntermediateDirectories:YES attributes:nil error:&error]) { + NSLog(@"[FLEX] Failed to create curl folder: %@", error.localizedDescription); + return nil; + } + + // Generate curl files + for (NSUInteger i = 0; i < transactions.count; i++) { + FLEXHTTPTransaction *transaction = transactions[i]; + NSURLRequest *request = transaction.request; + + // Generate curl command + NSString *curlCommand = [FLEXNetworkCurlLogger curlCommandString:request]; + + // Create filename from URL path + NSString *urlPath = request.URL.lastPathComponent ?: @"request"; + urlPath = [urlPath stringByReplacingOccurrencesOfString:@"/" withString:@"_"]; + urlPath = [urlPath stringByReplacingOccurrencesOfString:@"?" withString:@"_"]; + if (urlPath.length > 50) { + urlPath = [urlPath substringToIndex:50]; + } + + NSString *filename = [NSString stringWithFormat:@"%03lu_%@_%@.sh", + (unsigned long)(i + 1), + request.HTTPMethod ?: @"GET", + urlPath]; + + NSString *filePath = [curlFolderPath stringByAppendingPathComponent:filename]; + + // Add shebang and make executable + NSString *content = [NSString stringWithFormat:@"#!/bin/bash\n# %@\n\n%@\n", + request.URL.absoluteString, curlCommand]; + + [content writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + } + + // Create README + NSString *readme = [NSString stringWithFormat: + @"# FLEX Network Export - Curl Commands\n\n" + @"Exported: %@\n" + @"Total Requests: %lu\n\n" + @"## Usage\n\n" + @"```bash\n" + @"chmod +x *.sh\n" + @"./001_GET_endpoint.sh\n" + @"```\n\n" + @"## Run All\n\n" + @"```bash\n" + @"for f in *.sh; do bash \"$f\"; done\n" + @"```\n", + [formatter stringFromDate:[NSDate date]], + (unsigned long)transactions.count]; + NSString *readmePath = [curlFolderPath stringByAppendingPathComponent:@"README.md"]; + [readme writeToFile:readmePath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + + // Create ZIP using NSFileCoordinator + NSURL *folderURL = [NSURL fileURLWithPath:curlFolderPath isDirectory:YES]; + NSURL *zipURL = [NSURL fileURLWithPath:zipPath]; + + NSFileCoordinator *coordinator = [[NSFileCoordinator alloc] init]; + __block BOOL success = NO; + __block NSError *coordinatorError = nil; + + [coordinator coordinateReadingItemAtURL:folderURL + options:NSFileCoordinatorReadingForUploading + error:&coordinatorError + byAccessor:^(NSURL *newURL) { + NSError *copyError = nil; + success = [fileManager copyItemAtURL:newURL toURL:zipURL error:©Error]; + if (!success) { + NSLog(@"[FLEX] Failed to create ZIP: %@", copyError.localizedDescription); + } + }]; + + // Cleanup folder + [fileManager removeItemAtPath:curlFolderPath error:nil]; + + if (!success || coordinatorError) { + return nil; + } + + return zipURL; +} + +#pragma mark - Filtering + ++ (NSArray *)filterTransactionsForExport:(NSArray *)transactions +{ + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + + BOOL excludeImages = defaults.flex_exportExcludeImages; + BOOL excludeAnalytics = defaults.flex_exportExcludeAnalytics; + BOOL excludeFirebaseAnalytics = defaults.flex_exportExcludeFirebaseAnalytics; + + // If no filters enabled, return as-is + if (!excludeImages && !excludeAnalytics && !excludeFirebaseAnalytics) { + return transactions; + } + + // Analytics hosts to exclude + NSArray *analyticsHosts = @[ + // Mobile attribution & analytics + @"adjust.com", + @"adjust.io", + @"adj.st", + @"appsflyer.com", + @"onelink.me", + @"app.link", + @"branch.io", + @"amplitude.com", + @"mixpanel.com", + @"segment.io", + @"segment.com", + @"segmentapis.com", + @"kochava.com", + @"singular.net", + @"tenjin.io", + @"tenjin.com", + + // Facebook/Meta + @"facebook.com", + @"facebook.net", + @"graph.facebook.com", + @"fbcdn.net", + @"fb.com", + @"fb.gg", + @"facebookanalytics", + + // Google Analytics + @"google-analytics.com", + @"googleanalytics.com", + @"analytics.google.com", + @"doubleclick.net", + + // Advertising & tracking + @"criteo.com", + @"criteo.net", + @"mopub.com", + @"applovin.com", + @"unity3d.com", + @"unityads.unity3d.com", + @"ironsrc.com", + @"ironsource.com", + @"vungle.com", + @"chartboost.com", + @"adcolony.com", + @"inmobi.com", + @"tapjoy.com", + @"fyber.com", + @"liftoff.io", + + // Crash & performance + @"crashlytics.com", + @"bugsnag.com", + @"sentry.io", + @"raygun.io", + @"instabug.com", + + // Other analytics + @"flurry.com", + @"localytics.com", + @"braze.com", + @"appboy.com", + @"clevertap.com", + @"moengage.com", + @"leanplum.com", + @"airship.com", + @"urbanairship.com", + @"onesignal.com", + @"batch.com", + @"uxcam.com", + @"smartlook.com", + @"hotjar.com", + @"fullstory.com", + @"heap.io", + @"heapanalytics.com", + @"countly.com", + @"appmetrica", + @"apptimize.com", + @"optimizely.com", + @"abtasty.com", + @"launchdarkly.com", + ]; + + // Image extensions + NSArray *imageExtensions = @[ @".jpg", @".jpeg", @".png", @".gif", @".webp", @".svg", @".ico", @".bmp", @".heic", @".heif" ]; + + // Firebase Analytics patterns (but NOT Remote Config) + NSArray *firebaseAnalyticsPatterns = @[ + @"app-measurement.com", + @"firebase-analytics", + @"firebaseanalytics", + @"google-analytics.com/g/collect", + @"analyticsdata.googleapis.com", + ]; + + return [transactions filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(FLEXHTTPTransaction *transaction, NSDictionary *bindings) { + NSString *urlString = transaction.request.URL.absoluteString.lowercaseString; + NSString *host = transaction.request.URL.host.lowercaseString ?: @""; + NSString *contentType = [transaction.response.MIMEType lowercaseString] ?: @""; + + // Exclude images + if (excludeImages) { + // Check Content-Type + if ([contentType hasPrefix:@"image/"]) { + return NO; + } + // Check URL extension + for (NSString *ext in imageExtensions) { + if ([urlString containsString:ext]) { + return NO; + } + } + } + + // Exclude analytics + if (excludeAnalytics) { + for (NSString *analyticsHost in analyticsHosts) { + if ([host containsString:analyticsHost] || [urlString containsString:analyticsHost]) { + return NO; + } + } + } + + // Exclude Firebase Analytics (but keep Remote Config) + if (excludeFirebaseAnalytics) { + // First check if this is Remote Config - always keep + if ([urlString containsString:@"remoteconfig"] || + [urlString containsString:@"firebaseremoteconfig"] || + [host containsString:@"firebaseremoteconfig"]) { + return YES; + } + + // Check Firebase Analytics patterns + for (NSString *pattern in firebaseAnalyticsPatterns) { + if ([urlString containsString:pattern] || [host containsString:pattern]) { + return NO; + } + } + } + + return YES; + }]]; +} + +#pragma mark - File Operations + ++ (NSURL *)saveToTemporaryFile:(NSString *)content withFilename:(NSString *)filename +{ + NSString *tempDir = NSTemporaryDirectory(); + NSString *filePath = [tempDir stringByAppendingPathComponent:filename]; + + NSError *error = nil; + BOOL success = [content writeToFile:filePath + atomically:YES + encoding:NSUTF8StringEncoding + error:&error]; + + if (!success || error) { + NSLog(@"[FLEX] Failed to save export file: %@", error.localizedDescription); + return nil; + } + + return [NSURL fileURLWithPath:filePath]; +} + ++ (NSString *)suggestedFilenameForFormat:(FLEXNetworkExportFormat)format isMultiple:(BOOL)isMultiple +{ + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + formatter.dateFormat = @"yyyyMMdd_HHmmss"; + NSString *timestamp = [formatter stringFromDate:[NSDate date]]; + + NSString *prefix = isMultiple ? @"network_export" : @"request"; + + switch (format) { + case FLEXNetworkExportFormatRequestOnly: + return [NSString stringWithFormat:@"%@_%@_request.txt", prefix, timestamp]; + case FLEXNetworkExportFormatResponseOnly: + return [NSString stringWithFormat:@"%@_%@_response.txt", prefix, timestamp]; + case FLEXNetworkExportFormatRaw: + return [NSString stringWithFormat:@"%@_%@.txt", prefix, timestamp]; + case FLEXNetworkExportFormatHAR: + return [NSString stringWithFormat:@"%@_%@.har", prefix, timestamp]; + case FLEXNetworkExportFormatPostman: + return [NSString stringWithFormat:@"%@_%@_postman.json", prefix, timestamp]; + case FLEXNetworkExportFormatSwagger: + return [NSString stringWithFormat:@"%@_%@_swagger.json", prefix, timestamp]; + case FLEXNetworkExportFormatCurlZip: + return [NSString stringWithFormat:@"curl_requests_%@.zip", timestamp]; + } +} + +@end diff --git a/Classes/Network/FLEXNetworkMITMViewController.m b/Classes/Network/FLEXNetworkMITMViewController.m index 54314b879d..9071875fff 100644 --- a/Classes/Network/FLEXNetworkMITMViewController.m +++ b/Classes/Network/FLEXNetworkMITMViewController.m @@ -6,22 +6,24 @@ // Copyright (c) 2020 FLEX Team. All rights reserved. // +#import "FLEXNetworkMITMViewController.h" +#import "FLEXActivityViewController.h" #import "FLEXColor.h" -#import "FLEXUtility.h" +#import "FLEXGlobalsViewController.h" +#import "FLEXHTTPTransactionDetailController.h" #import "FLEXMITMDataSource.h" -#import "FLEXNetworkMITMViewController.h" -#import "FLEXNetworkTransaction.h" -#import "FLEXNetworkRecorder.h" +#import "FLEXNetworkExporter.h" #import "FLEXNetworkObserver.h" -#import "FLEXNetworkTransactionCell.h" -#import "FLEXHTTPTransactionDetailController.h" +#import "FLEXNetworkRecorder.h" #import "FLEXNetworkSettingsController.h" +#import "FLEXNetworkTransaction.h" +#import "FLEXNetworkTransactionCell.h" #import "FLEXObjectExplorerFactory.h" -#import "FLEXGlobalsViewController.h" -#import "FLEXWebViewController.h" -#import "UIBarButtonItem+FLEX.h" #import "FLEXResources.h" +#import "FLEXUtility.h" +#import "FLEXWebViewController.h" #import "NSUserDefaults+FLEX.h" +#import "UIBarButtonItem+FLEX.h" #define kFirebaseAvailable NSClassFromString(@"FIRDocumentReference") #define kWebsocketsAvailable @available(iOS 13.0, *) @@ -50,18 +52,20 @@ @implementation FLEXNetworkMITMViewController #pragma mark - Lifecycle -- (id)init { +- (id)init +{ return [self initWithStyle:UITableViewStylePlain]; } -- (void)viewDidLoad { +- (void)viewDidLoad +{ [super viewDidLoad]; self.showsSearchBar = YES; self.pinSearchBar = YES; self.showSearchBarInitially = NO; NSMutableArray *scopeTitles = [NSMutableArray arrayWithObject:@"REST"]; - + _HTTPDataSource = [FLEXMITMDataSource dataSourceWithProvider:^NSArray * { return FLEXNetworkRecorder.defaultRecorder.HTTPTransactions; }]; @@ -79,7 +83,7 @@ - (void)viewDidLoad { return FLEXNetworkRecorder.defaultRecorder.websocketTransactions; }]; } - + // Scopes will only be shown if we have either firebase or websockets available self.searchController.searchBar.showsScopeBar = scopeTitles.count > 1; self.searchController.searchBar.scopeButtonTitles = scopeTitles; @@ -88,20 +92,21 @@ - (void)viewDidLoad { [self addToolbarItems:@[ [UIBarButtonItem flex_itemWithImage:FLEXResources.gearIcon - target:self - action:@selector(settingsButtonTapped:) - ], + target:self + action:@selector(settingsButtonTapped:)], + [UIBarButtonItem + flex_systemItem:UIBarButtonSystemItemAction + target:self + action:@selector(exportButtonTapped:)], [[UIBarButtonItem - flex_systemItem:UIBarButtonSystemItemTrash - target:self - action:@selector(trashButtonTapped:) - ] flex_withTintColor:UIColor.redColor] + flex_systemItem:UIBarButtonSystemItemTrash + target:self + action:@selector(trashButtonTapped:)] flex_withTintColor:UIColor.redColor] ]]; [self.tableView - registerClass:FLEXNetworkTransactionCell.class - forCellReuseIdentifier:FLEXNetworkTransactionCell.reuseID - ]; + registerClass:FLEXNetworkTransactionCell.class + forCellReuseIdentifier:FLEXNetworkTransactionCell.reuseID]; self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; self.tableView.rowHeight = FLEXNetworkTransactionCell.preferredCellHeight; @@ -109,9 +114,10 @@ - (void)viewDidLoad { [self updateTransactions:nil]; } -- (void)viewWillAppear:(BOOL)animated { +- (void)viewWillAppear:(BOOL)animated +{ [super viewWillAppear:animated]; - + // Reload the table if we received updates while not on-screen if (self.pendingReload) { [self.tableView reloadData]; @@ -119,26 +125,29 @@ - (void)viewWillAppear:(BOOL)animated { } } -- (void)dealloc { +- (void)dealloc +{ [NSNotificationCenter.defaultCenter removeObserver:self]; } -- (void)registerForNotifications { +- (void)registerForNotifications +{ NSDictionary *notifications = @{ - kFLEXNetworkRecorderNewTransactionNotification: + kFLEXNetworkRecorderNewTransactionNotification : NSStringFromSelector(@selector(handleNewTransactionRecordedNotification:)), - kFLEXNetworkRecorderTransactionUpdatedNotification: + kFLEXNetworkRecorderTransactionUpdatedNotification : NSStringFromSelector(@selector(handleTransactionUpdatedNotification:)), - kFLEXNetworkRecorderTransactionsClearedNotification: + kFLEXNetworkRecorderTransactionsClearedNotification : NSStringFromSelector(@selector(handleTransactionsClearedNotification:)), - kFLEXNetworkObserverEnabledStateChangedNotification: + kFLEXNetworkObserverEnabledStateChangedNotification : NSStringFromSelector(@selector(handleNetworkObserverEnabledStateChangedNotification:)), }; - + for (NSString *name in notifications.allKeys) { [NSNotificationCenter.defaultCenter addObserver:self - selector:NSSelectorFromString(notifications[name]) name:name object:nil - ]; + selector:NSSelectorFromString(notifications[name]) + name:name + object:nil]; } } @@ -147,49 +156,279 @@ - (void)registerForNotifications { #pragma mark Button Actions -- (void)settingsButtonTapped:(UIBarButtonItem *)sender { +- (void)settingsButtonTapped:(UIBarButtonItem *)sender +{ UIViewController *settings = [FLEXNetworkSettingsController new]; settings.navigationItem.rightBarButtonItem = FLEXBarButtonItemSystem( - Done, self, @selector(settingsViewControllerDoneTapped:) - ); + Done, self, @selector(settingsViewControllerDoneTapped:)); settings.title = @"Network Debugging Settings"; - + // This is not a FLEXNavigationController because it is not intended as a new tab UIViewController *nav = [[UINavigationController alloc] initWithRootViewController:settings]; [self presentViewController:nav animated:YES completion:nil]; } -- (void)trashButtonTapped:(UIBarButtonItem *)sender { - [FLEXAlert makeSheet:^(FLEXAlert *make) { - BOOL clearAll = !self.dataSource.isFiltered; - if (!clearAll) { - make.title(@"Clear Filtered Requests?"); - make.message(@"This will only remove the requests matching your search string on this screen."); - } else { - make.title(@"Clear All Recorded Requests?"); - make.message(@"This cannot be undone."); - } - - make.button(@"Cancel").cancelStyle(); - make.button(@"Clear").destructiveStyle().handler(^(NSArray *strings) { - if (clearAll) { - [FLEXNetworkRecorder.defaultRecorder clearRecordedActivity]; +- (void)trashButtonTapped:(UIBarButtonItem *)sender +{ + [FLEXAlert + makeSheet:^(FLEXAlert *make) { + BOOL clearAll = !self.dataSource.isFiltered; + if (!clearAll) { + make.title(@"Clear Filtered Requests?"); + make.message(@"This will only remove the requests matching your search string on this screen."); } else { - FLEXNetworkTransactionKind kind = (FLEXNetworkTransactionKind)self.mode; - [FLEXNetworkRecorder.defaultRecorder clearRecordedActivity:kind matching:self.searchText]; + make.title(@"Clear All Recorded Requests?"); + make.message(@"This cannot be undone."); } - }); - } showFrom:self source:sender]; + + make.button(@"Cancel").cancelStyle(); + make.button(@"Clear").destructiveStyle().handler(^(NSArray *strings) { + if (clearAll) { + [FLEXNetworkRecorder.defaultRecorder clearRecordedActivity]; + } else { + FLEXNetworkTransactionKind kind = (FLEXNetworkTransactionKind)self.mode; + [FLEXNetworkRecorder.defaultRecorder clearRecordedActivity:kind matching:self.searchText]; + } + }); + } + showFrom:self + source:sender]; } -- (void)settingsViewControllerDoneTapped:(id)sender { +- (void)settingsViewControllerDoneTapped:(id)sender +{ [self dismissViewControllerAnimated:YES completion:nil]; } +- (void)exportButtonTapped:(UIBarButtonItem *)sender +{ + // Check if we're in REST mode (HTTP transactions) + if (self.mode != FLEXNetworkObserverModeREST) { + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"Export Not Available"); + make.message(@"Export is currently only available for REST/HTTP requests."); + make.button(@"OK").cancelStyle(); + } + showFrom:self]; + return; + } + + NSArray *transactions = self.HTTPDataSource.transactions; + + if (transactions.count == 0) { + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"No Requests to Export"); + make.message(@"There are no network requests to export."); + make.button(@"OK").cancelStyle(); + } + showFrom:self]; + return; + } + + [FLEXAlert + makeSheet:^(FLEXAlert *make) { + make.title(@"Export Network Requests"); + make.message([NSString stringWithFormat:@"%lu request(s) available", (unsigned long)transactions.count]); + + // Export all as HAR + make.button(@"Export All as HAR").handler(^(NSArray *strings) { + [self exportTransactions:transactions format:FLEXNetworkExportFormatHAR sender:sender]; + }); + + // Export all as Postman Collection + make.button(@"Export as Postman Collection").handler(^(NSArray *strings) { + [self exportTransactions:transactions format:FLEXNetworkExportFormatPostman sender:sender]; + }); + + // Export all as Swagger/OpenAPI + make.button(@"Export as Swagger/OpenAPI").handler(^(NSArray *strings) { + [self exportTransactions:transactions format:FLEXNetworkExportFormatSwagger sender:sender]; + }); + + // Export all as Raw Text + make.button(@"Export All as Raw Text").handler(^(NSArray *strings) { + [self exportTransactions:transactions format:FLEXNetworkExportFormatRaw sender:sender]; + }); + + // Export as Curl commands ZIP + make.button(@"Export as Curl Scripts (ZIP)").handler(^(NSArray *strings) { + [self exportTransactions:transactions format:FLEXNetworkExportFormatCurlZip sender:sender]; + }); + + // Export filtered/visible if search is active + if (self.HTTPDataSource.isFiltered && transactions.count > 0) { + make.button([NSString stringWithFormat:@"Export Filtered (%lu) as HAR", (unsigned long)transactions.count]).handler(^(NSArray *strings) { + [self exportTransactions:transactions format:FLEXNetworkExportFormatHAR sender:sender]; + }); + } + + make.button(@"Cancel").cancelStyle(); + } + showFrom:self + source:sender]; +} +- (void)exportTransactions:(NSArray *)transactions + format:(FLEXNetworkExportFormat)format + sender:(UIBarButtonItem *)sender +{ + // Create HUD overlay + UIView *hudView = [[UIView alloc] initWithFrame:self.view.bounds]; + hudView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.5]; + hudView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + // Container for spinner and label + UIView *container = [[UIView alloc] init]; + container.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.95]; + container.layer.cornerRadius = 12; + container.translatesAutoresizingMaskIntoConstraints = NO; + [hudView addSubview:container]; + + UIActivityIndicatorView *spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + spinner.translatesAutoresizingMaskIntoConstraints = NO; + [spinner startAnimating]; + [container addSubview:spinner]; + + UILabel *label = [[UILabel alloc] init]; + label.text = @"Exporting..."; + label.textColor = [UIColor darkGrayColor]; + label.font = [UIFont systemFontOfSize:14]; + label.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:label]; + + // Layout constraints + [NSLayoutConstraint activateConstraints:@[ + [container.centerXAnchor constraintEqualToAnchor:hudView.centerXAnchor], + [container.centerYAnchor constraintEqualToAnchor:hudView.centerYAnchor], + [container.widthAnchor constraintEqualToConstant:140], + [container.heightAnchor constraintEqualToConstant:80], + [spinner.centerXAnchor constraintEqualToAnchor:container.centerXAnchor], + [spinner.topAnchor constraintEqualToAnchor:container.topAnchor + constant:16], + [label.centerXAnchor constraintEqualToAnchor:container.centerXAnchor], + [label.topAnchor constraintEqualToAnchor:spinner.bottomAnchor + constant:8], + ]]; + + [self.view addSubview:hudView]; + + // Helper block to remove HUD + void (^removeHUD)(void) = ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [hudView removeFromSuperview]; + }); + }; + + // Perform export on background thread + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // Apply export filters based on user settings + NSArray *filteredTransactions = [FLEXNetworkExporter filterTransactionsForExport:transactions]; + + if (filteredTransactions.count == 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + removeHUD(); + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"No Requests to Export"); + make.message(@"All requests were filtered out. Check your export filter settings."); + make.button(@"OK").cancelStyle(); + } + showFrom:self]; + }); + return; + } + + NSString *content = nil; + NSString *filename = nil; + NSURL *zipURL = nil; + + switch (format) { + case FLEXNetworkExportFormatHAR: + content = [FLEXNetworkExporter harJSONStringForTransactions:filteredTransactions]; + filename = [FLEXNetworkExporter suggestedFilenameForFormat:format isMultiple:YES]; + break; + case FLEXNetworkExportFormatRaw: + content = [FLEXNetworkExporter rawStringForTransactions:filteredTransactions]; + filename = [FLEXNetworkExporter suggestedFilenameForFormat:format isMultiple:YES]; + break; + case FLEXNetworkExportFormatPostman: + content = [FLEXNetworkExporter postmanCollectionForTransactions:filteredTransactions]; + filename = [FLEXNetworkExporter suggestedFilenameForFormat:format isMultiple:YES]; + break; + case FLEXNetworkExportFormatSwagger: + content = [FLEXNetworkExporter swaggerSpecForTransactions:filteredTransactions]; + filename = [FLEXNetworkExporter suggestedFilenameForFormat:format isMultiple:YES]; + break; + case FLEXNetworkExportFormatCurlZip: + zipURL = [FLEXNetworkExporter curlZipForTransactions:filteredTransactions]; + break; + default: + removeHUD(); + return; + } + + // Handle ZIP export + if (format == FLEXNetworkExportFormatCurlZip) { + dispatch_async(dispatch_get_main_queue(), ^{ + removeHUD(); + if (!zipURL) { + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"Export Failed"); + make.message(@"Failed to create ZIP file."); + make.button(@"OK").cancelStyle(); + } + showFrom:self]; + return; + } + UIViewController *activityVC = [FLEXActivityViewController sharing:@[ zipURL ] source:sender]; + [self presentViewController:activityVC animated:YES completion:nil]; + }); + return; + } + + // Handle other exports + if (!content) { + dispatch_async(dispatch_get_main_queue(), ^{ + removeHUD(); + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"Export Failed"); + make.message(@"Failed to generate export content."); + make.button(@"OK").cancelStyle(); + } + showFrom:self]; + }); + return; + } + + NSURL *fileURL = [FLEXNetworkExporter saveToTemporaryFile:content withFilename:filename]; + + dispatch_async(dispatch_get_main_queue(), ^{ + removeHUD(); + if (!fileURL) { + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"Export Failed"); + make.message(@"Failed to save export file."); + make.button(@"OK").cancelStyle(); + } + showFrom:self]; + return; + } + + UIViewController *activityVC = [FLEXActivityViewController sharing:@[ fileURL ] source:sender]; + [self presentViewController:activityVC animated:YES completion:nil]; + }); + }); +} + #pragma mark Transactions -- (FLEXNetworkObserverMode)mode { +- (FLEXNetworkObserverMode)mode +{ FLEXNetworkObserverMode mode = self.searchController.searchBar.selectedScopeButtonIndex; switch (mode) { case FLEXNetworkObserverModeFirebase: @@ -209,30 +448,31 @@ - (FLEXNetworkObserverMode)mode { } } -- (void)setMode:(FLEXNetworkObserverMode)mode { -// The segmentd control will have different appearances based on which APIs -// are available. For example, when only Websockets is available: -// -// 0 1 -// ┌───────────────────────────┬────────────────────────────┐ -// │ REST │ Websockets │ -// └───────────────────────────┴────────────────────────────┘ -// -// And when both Firebase and Websockets are available: -// -// 0 1 2 -// ┌──────────────────┬──────────────────┬──────────────────┐ -// │ Firebase │ REST │ Websockets │ -// └──────────────────┴──────────────────┴──────────────────┘ -// -// As a result, we need to adjust the input mode variable accordingly -// before we actually set it. When we try to set it to Firebase but -// Firebase is not available, we don't do anything, because when Firebase -// is unavailable, FLEXNetworkObserverModeFirebase represents the same index -// as REST would without Firebase. For each of the others, we subtract 1 -// from them for every relevant API that is unavailable. So for Websockets, -// if it is unavailable, we subtract 1 and it becomes FLEXNetworkObserverModeREST. -// And if Firebase is also unavailable, we subtract 1 again. +- (void)setMode:(FLEXNetworkObserverMode)mode +{ + // The segmentd control will have different appearances based on which APIs + // are available. For example, when only Websockets is available: + // + // 0 1 + // ┌───────────────────────────┬────────────────────────────┐ + // │ REST │ Websockets │ + // └───────────────────────────┴────────────────────────────┘ + // + // And when both Firebase and Websockets are available: + // + // 0 1 2 + // ┌──────────────────┬──────────────────┬──────────────────┐ + // │ Firebase │ REST │ Websockets │ + // └──────────────────┴──────────────────┴──────────────────┘ + // + // As a result, we need to adjust the input mode variable accordingly + // before we actually set it. When we try to set it to Firebase but + // Firebase is not available, we don't do anything, because when Firebase + // is unavailable, FLEXNetworkObserverModeFirebase represents the same index + // as REST would without Firebase. For each of the others, we subtract 1 + // from them for every relevant API that is unavailable. So for Websockets, + // if it is unavailable, we subtract 1 and it becomes FLEXNetworkObserverModeREST. + // And if Firebase is also unavailable, we subtract 1 again. switch (mode) { case FLEXNetworkObserverModeFirebase: @@ -258,7 +498,8 @@ - (void)setMode:(FLEXNetworkObserverMode)mode { self.searchController.searchBar.selectedScopeButtonIndex = mode; } -- (FLEXMITMDataSource *)dataSource { +- (FLEXMITMDataSource *)dataSource +{ switch (self.mode) { case FLEXNetworkObserverModeREST: return self.HTTPDataSource; @@ -269,13 +510,15 @@ - (void)setMode:(FLEXNetworkObserverMode)mode { } } -- (void)updateTransactions:(void(^)(void))callback { +- (void)updateTransactions:(void (^)(void))callback +{ id completion = ^(FLEXMITMDataSource *dataSource) { // Update byte count [self updateFirstSectionHeader]; - if (callback && dataSource == self.dataSource) callback(); + if (callback && dataSource == self.dataSource) + callback(); }; - + [self.HTTPDataSource reloadData:completion]; [self.websocketDataSource reloadData:completion]; [self.firebaseDataSource reloadData:completion]; @@ -284,7 +527,8 @@ - (void)updateTransactions:(void(^)(void))callback { #pragma mark Header -- (void)updateFirstSectionHeader { +- (void)updateFirstSectionHeader +{ UIView *view = [self.tableView headerViewForSection:0]; if ([view isKindOfClass:[UITableViewHeaderFooterView class]]) { UITableViewHeaderFooterView *headerView = (UITableViewHeaderFooterView *)view; @@ -293,58 +537,58 @@ - (void)updateFirstSectionHeader { } } -- (NSString *)headerText { +- (NSString *)headerText +{ long long bytesReceived = self.dataSource.bytesReceived; NSInteger totalRequests = self.dataSource.transactions.count; - + NSString *byteCountText = [NSByteCountFormatter - stringFromByteCount:bytesReceived countStyle:NSByteCountFormatterCountStyleBinary - ]; + stringFromByteCount:bytesReceived + countStyle:NSByteCountFormatterCountStyleBinary]; NSString *requestsText = totalRequests == 1 ? @"Request" : @"Requests"; - + // Exclude byte count from Firebase if (self.mode == FLEXNetworkObserverModeFirebase) { return [NSString stringWithFormat:@"%@ %@", - @(totalRequests), requestsText - ]; + @(totalRequests), requestsText]; } - + return [NSString stringWithFormat:@"%@ %@ (%@ received)", - @(totalRequests), requestsText, byteCountText - ]; + @(totalRequests), requestsText, byteCountText]; } #pragma mark - FLEXGlobalsEntry -+ (NSString *)globalsEntryTitle:(FLEXGlobalsRow)row { ++ (NSString *)globalsEntryTitle:(FLEXGlobalsRow)row +{ return @"📡 Network History"; } -+ (FLEXGlobalsEntryRowAction)globalsEntryRowAction:(FLEXGlobalsRow)row { ++ (FLEXGlobalsEntryRowAction)globalsEntryRowAction:(FLEXGlobalsRow)row +{ return ^(UITableViewController *host) { if (FLEXNetworkObserver.isEnabled) { - [host.navigationController pushViewController:[ - self globalsEntryViewController:row - ] animated:YES]; + [host.navigationController pushViewController:[self globalsEntryViewController:row] animated:YES]; } else { - [FLEXAlert makeAlert:^(FLEXAlert *make) { - make.title(@"Network Monitor Disabled"); - make.message(@"You must enable network monitoring to proceed."); - - make.button(@"Turn On").preferred().handler(^(NSArray *strings) { - FLEXNetworkObserver.enabled = YES; - [host.navigationController pushViewController:[ - self globalsEntryViewController:row - ] animated:YES]; - }); - make.button(@"Dismiss").cancelStyle(); - } showFrom:host]; + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"Network Monitor Disabled"); + make.message(@"You must enable network monitoring to proceed."); + + make.button(@"Turn On").preferred().handler(^(NSArray *strings) { + FLEXNetworkObserver.enabled = YES; + [host.navigationController pushViewController:[self globalsEntryViewController:row] animated:YES]; + }); + make.button(@"Dismiss").cancelStyle(); + } + showFrom:host]; } }; } -+ (UIViewController *)globalsEntryViewController:(FLEXGlobalsRow)row { ++ (UIViewController *)globalsEntryViewController:(FLEXGlobalsRow)row +{ UIViewController *controller = [self new]; controller.title = [self globalsEntryTitle:row]; return controller; @@ -353,57 +597,59 @@ + (UIViewController *)globalsEntryViewController:(FLEXGlobalsRow)row { #pragma mark - Notification Handlers -- (void)handleNewTransactionRecordedNotification:(NSNotification *)notification { +- (void)handleNewTransactionRecordedNotification:(NSNotification *)notification +{ [self tryUpdateTransactions]; } -- (void)tryUpdateTransactions { +- (void)tryUpdateTransactions +{ // Don't do any view updating if we aren't in the view hierarchy if (!self.viewIfLoaded.window) { [self updateTransactions:nil]; self.pendingReload = YES; return; } - + // Let the previous row insert animation finish before starting a new one to avoid stomping. // We'll try calling the method again when the insertion completes, // and we properly no-op if there haven't been changes. if (self.updateInProgress) { return; } - + self.updateInProgress = YES; // Get state before update NSString *currentFilter = self.searchText; FLEXNetworkObserverMode currentMode = self.mode; NSInteger existingRowCount = self.dataSource.transactions.count; - + [self updateTransactions:^{ // Compare to state after update NSString *newFilter = self.searchText; FLEXNetworkObserverMode newMode = self.mode; NSInteger newRowCount = self.dataSource.transactions.count; NSInteger rowCountDiff = newRowCount - existingRowCount; - + // Abort if the observation mode changed, or if the search field text changed if (newMode != currentMode || ![currentFilter isEqualToString:newFilter]) { self.updateInProgress = NO; return; } - + if (rowCountDiff) { // Insert animation if we're at the top. if (self.tableView.contentOffset.y <= 0.0 && rowCountDiff > 0) { [CATransaction begin]; - + [CATransaction setCompletionBlock:^{ self.updateInProgress = NO; // This isn't an infinite loop, it won't run a third time // if there were no new transactions the second time [self tryUpdateTransactions]; }]; - + NSMutableArray *indexPathsToReload = [NSMutableArray new]; for (NSInteger row = 0; row < rowCountDiff; row++) { [indexPathsToReload addObject:[NSIndexPath indexPathForRow:row inSection:0]]; @@ -425,7 +671,8 @@ - (void)tryUpdateTransactions { }]; } -- (void)handleTransactionUpdatedNotification:(NSNotification *)notification { +- (void)handleTransactionUpdatedNotification:(NSNotification *)notification +{ [self.HTTPDataSource reloadByteCounts]; [self.websocketDataSource reloadByteCounts]; // Don't need to reload Firebase here @@ -442,17 +689,19 @@ - (void)handleTransactionUpdatedNotification:(NSNotification *)notification { break; } } - + [self updateFirstSectionHeader]; } -- (void)handleTransactionsClearedNotification:(NSNotification *)notification { +- (void)handleTransactionsClearedNotification:(NSNotification *)notification +{ [self updateTransactions:^{ [self.tableView reloadData]; }]; } -- (void)handleNetworkObserverEnabledStateChangedNotification:(NSNotification *)notification { +- (void)handleNetworkObserverEnabledStateChangedNotification:(NSNotification *)notification +{ // Update the header, which displays a warning when network debugging is disabled [self updateFirstSectionHeader]; } @@ -460,27 +709,30 @@ - (void)handleNetworkObserverEnabledStateChangedNotification:(NSNotification *)n #pragma mark - Table view data source -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ return self.dataSource.transactions.count; } -- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ return [self headerText]; } -- (void)tableView:(UITableView *)tableView willDisplayHeaderView:(UIView *)view forSection:(NSInteger)section { +- (void)tableView:(UITableView *)tableView willDisplayHeaderView:(UIView *)view forSection:(NSInteger)section +{ if ([view isKindOfClass:[UITableViewHeaderFooterView class]]) { UITableViewHeaderFooterView *headerView = (UITableViewHeaderFooterView *)view; headerView.textLabel.font = [UIFont systemFontOfSize:14.0 weight:UIFontWeightSemibold]; } } -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ FLEXNetworkTransactionCell *cell = [tableView dequeueReusableCellWithIdentifier:FLEXNetworkTransactionCell.reuseID - forIndexPath:indexPath - ]; - + forIndexPath:indexPath]; + cell.transaction = [self transactionAtIndexPath:indexPath]; // Since we insert from the top, assign background colors bottom up to keep them consistent for each transaction. @@ -494,7 +746,8 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N return cell; } -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ switch (self.mode) { case FLEXNetworkObserverModeREST: { FLEXHTTPTransaction *transaction = [self HTTPTransactionAtIndexPath:indexPath]; @@ -502,26 +755,26 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath [self.navigationController pushViewController:details animated:YES]; break; } - + case FLEXNetworkObserverModeWebsockets: { if (@available(iOS 13.0, *)) { // This check will never fail FLEXWebsocketTransaction *transaction = [self websocketTransactionAtIndexPath:indexPath]; - + UIViewController *details = nil; if (transaction.message.type == NSURLSessionWebSocketMessageTypeData) { details = [FLEXObjectExplorerFactory explorerViewControllerForObject:transaction.message.data]; } else { details = [[FLEXWebViewController alloc] initWithText:transaction.message.string]; } - + [self.navigationController pushViewController:details animated:YES]; } break; } - + case FLEXNetworkObserverModeFirebase: { FLEXFirebaseTransaction *transaction = [self firebaseTransactionAtIndexPath:indexPath]; -// id obj = transaction.documents.count == 1 ? transaction.documents.firstObject : transaction.documents; + // id obj = transaction.documents.count == 1 ? transaction.documents.firstObject : transaction.documents; UIViewController *explorer = [FLEXObjectExplorerFactory explorerViewControllerForObject:transaction]; [self.navigationController pushViewController:explorer animated:YES]; } @@ -531,102 +784,111 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath #pragma mark - Menu Actions -- (BOOL)tableView:(UITableView *)tableView shouldShowMenuForRowAtIndexPath:(NSIndexPath *)indexPath { +- (BOOL)tableView:(UITableView *)tableView shouldShowMenuForRowAtIndexPath:(NSIndexPath *)indexPath +{ return YES; } -- (BOOL)tableView:(UITableView *)tableView canPerformAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender { +- (BOOL)tableView:(UITableView *)tableView canPerformAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender +{ return action == @selector(copy:); } -- (void)tableView:(UITableView *)tableView performAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender { +- (void)tableView:(UITableView *)tableView performAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender +{ if (action == @selector(copy:)) { UIPasteboard.generalPasteboard.string = [self transactionAtIndexPath:indexPath].copyString; } } -- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point __IOS_AVAILABLE(13.0) { - +- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point __IOS_AVAILABLE(13.0) +{ + FLEXNetworkTransaction *transaction = [self transactionAtIndexPath:indexPath]; - + return [UIContextMenuConfiguration configurationWithIdentifier:nil - previewProvider:nil - actionProvider:^UIMenu *(NSArray *suggestedActions) { - UIAction *copy = [UIAction - actionWithTitle:@"Copy URL" - image:nil - identifier:nil - handler:^(__kindof UIAction *action) { - UIPasteboard.generalPasteboard.string = transaction.copyString; - } - ]; - - NSArray *children = @[copy]; - if (self.mode == FLEXNetworkObserverModeREST) { - NSURLRequest *request = [self HTTPTransactionAtIndexPath:indexPath].request; - UIAction *denylist = [UIAction - actionWithTitle:[NSString stringWithFormat:@"Exclude '%@'", request.URL.host] - image:nil - identifier:nil - handler:^(__kindof UIAction *action) { - NSMutableArray *denylist = FLEXNetworkRecorder.defaultRecorder.hostDenylist; - [denylist addObject:request.URL.host]; - [FLEXNetworkRecorder.defaultRecorder clearExcludedTransactions]; - [FLEXNetworkRecorder.defaultRecorder synchronizeDenylist]; - [self tryUpdateTransactions]; - } - ]; - - children = [children arrayByAddingObject:denylist]; - } - return [UIMenu - menuWithTitle:@"" image:nil identifier:nil - options:UIMenuOptionsDisplayInline - children:children - ]; - } - ]; -} - -- (FLEXNetworkTransaction *)transactionAtIndexPath:(NSIndexPath *)indexPath { + previewProvider:nil + actionProvider:^UIMenu *(NSArray *suggestedActions) { + UIAction *copy = [UIAction + actionWithTitle:@"Copy URL" + image:nil + identifier:nil + handler:^(__kindof UIAction *action) { + UIPasteboard.generalPasteboard.string = transaction.copyString; + }]; + + NSArray *children = @[ copy ]; + if (self.mode == FLEXNetworkObserverModeREST) { + NSURLRequest *request = [self HTTPTransactionAtIndexPath:indexPath].request; + UIAction *denylist = [UIAction + actionWithTitle:[NSString stringWithFormat:@"Exclude '%@'", request.URL.host] + image:nil + identifier:nil + handler:^(__kindof UIAction *action) { + NSMutableArray *denylist = FLEXNetworkRecorder.defaultRecorder.hostDenylist; + [denylist addObject:request.URL.host]; + [FLEXNetworkRecorder.defaultRecorder clearExcludedTransactions]; + [FLEXNetworkRecorder.defaultRecorder synchronizeDenylist]; + [self tryUpdateTransactions]; + }]; + + children = [children arrayByAddingObject:denylist]; + } + return [UIMenu + menuWithTitle:@"" + image:nil + identifier:nil + options:UIMenuOptionsDisplayInline + children:children]; + }]; +} + +- (FLEXNetworkTransaction *)transactionAtIndexPath:(NSIndexPath *)indexPath +{ return self.dataSource.transactions[indexPath.row]; } -- (FLEXHTTPTransaction *)HTTPTransactionAtIndexPath:(NSIndexPath *)indexPath { +- (FLEXHTTPTransaction *)HTTPTransactionAtIndexPath:(NSIndexPath *)indexPath +{ return self.HTTPDataSource.transactions[indexPath.row]; } -- (FLEXWebsocketTransaction *)websocketTransactionAtIndexPath:(NSIndexPath *)indexPath { +- (FLEXWebsocketTransaction *)websocketTransactionAtIndexPath:(NSIndexPath *)indexPath +{ return self.websocketDataSource.transactions[indexPath.row]; } -- (FLEXFirebaseTransaction *)firebaseTransactionAtIndexPath:(NSIndexPath *)indexPath { +- (FLEXFirebaseTransaction *)firebaseTransactionAtIndexPath:(NSIndexPath *)indexPath +{ return self.firebaseDataSource.transactions[indexPath.row]; } #pragma mark - Search Bar -- (void)updateSearchResults:(NSString *)searchString { +- (void)updateSearchResults:(NSString *)searchString +{ id callback = ^(FLEXMITMDataSource *dataSource) { if (self.dataSource == dataSource) { [self.tableView reloadData]; } }; - + [self.HTTPDataSource filter:searchString completion:callback]; [self.websocketDataSource filter:searchString completion:callback]; [self.firebaseDataSource filter:searchString completion:callback]; } -- (void)searchBar:(UISearchBar *)searchBar selectedScopeButtonIndexDidChange:(NSInteger)newScope { +- (void)searchBar:(UISearchBar *)searchBar selectedScopeButtonIndexDidChange:(NSInteger)newScope +{ [self updateFirstSectionHeader]; [self.tableView reloadData]; NSUserDefaults.standardUserDefaults.flex_lastNetworkObserverMode = self.mode; } -- (void)willDismissSearchController:(UISearchController *)searchController { +- (void)willDismissSearchController:(UISearchController *)searchController +{ [self.tableView reloadData]; } diff --git a/Classes/Network/FLEXNetworkSettingsController.m b/Classes/Network/FLEXNetworkSettingsController.m index 4d57ea9391..015d2283ef 100644 --- a/Classes/Network/FLEXNetworkSettingsController.m +++ b/Classes/Network/FLEXNetworkSettingsController.m @@ -6,11 +6,11 @@ // #import "FLEXNetworkSettingsController.h" +#import "FLEXColor.h" #import "FLEXNetworkObserver.h" #import "FLEXNetworkRecorder.h" -#import "FLEXUtility.h" #import "FLEXTableView.h" -#import "FLEXColor.h" +#import "FLEXUtility.h" #import "NSUserDefaults+FLEX.h" @interface FLEXNetworkSettingsController () @@ -24,61 +24,84 @@ @interface FLEXNetworkSettingsController () @property (nonatomic) UILabel *cacheLimitLabel; @property (nonatomic) NSMutableArray *hostDenylist; + +@property (nonatomic, readonly) UISwitch *excludeImagesSwitch; +@property (nonatomic, readonly) UISwitch *excludeAnalyticsSwitch; +@property (nonatomic, readonly) UISwitch *excludeFirebaseAnalyticsSwitch; @end @implementation FLEXNetworkSettingsController -- (void)viewDidLoad { +- (void)viewDidLoad +{ [super viewDidLoad]; - + [self disableToolbar]; self.hostDenylist = FLEXNetworkRecorder.defaultRecorder.hostDenylist.mutableCopy; - + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - + _observerSwitch = [UISwitch new]; _cacheMediaSwitch = [UISwitch new]; _jsonViewerSwitch = [UISwitch new]; _cacheLimitSlider = [UISlider new]; - + self.observerSwitch.on = FLEXNetworkObserver.enabled; [self.observerSwitch addTarget:self - action:@selector(networkDebuggingToggled:) - forControlEvents:UIControlEventValueChanged - ]; - + action:@selector(networkDebuggingToggled:) + forControlEvents:UIControlEventValueChanged]; + self.cacheMediaSwitch.on = FLEXNetworkRecorder.defaultRecorder.shouldCacheMediaResponses; [self.cacheMediaSwitch addTarget:self - action:@selector(cacheMediaResponsesToggled:) - forControlEvents:UIControlEventValueChanged - ]; - + action:@selector(cacheMediaResponsesToggled:) + forControlEvents:UIControlEventValueChanged]; + self.jsonViewerSwitch.on = defaults.flex_registerDictionaryJSONViewerOnLaunch; [self.jsonViewerSwitch addTarget:self - action:@selector(jsonViewerSettingToggled:) - forControlEvents:UIControlEventValueChanged - ]; - + action:@selector(jsonViewerSettingToggled:) + forControlEvents:UIControlEventValueChanged]; + [self.cacheLimitSlider addTarget:self - action:@selector(cacheLimitAdjusted:) - forControlEvents:UIControlEventValueChanged - ]; - + action:@selector(cacheLimitAdjusted:) + forControlEvents:UIControlEventValueChanged]; + UISlider *slider = self.cacheLimitSlider; self.cacheLimitValue = FLEXNetworkRecorder.defaultRecorder.responseCacheByteLimit; const NSUInteger fiftyMega = 50 * 1024 * 1024; slider.minimumValue = 0; slider.maximumValue = fiftyMega; slider.value = self.cacheLimitValue; + + // Export filter switches + _excludeImagesSwitch = [UISwitch new]; + _excludeAnalyticsSwitch = [UISwitch new]; + _excludeFirebaseAnalyticsSwitch = [UISwitch new]; + + self.excludeImagesSwitch.on = defaults.flex_exportExcludeImages; + [self.excludeImagesSwitch addTarget:self + action:@selector(excludeImagesToggled:) + forControlEvents:UIControlEventValueChanged]; + + self.excludeAnalyticsSwitch.on = defaults.flex_exportExcludeAnalytics; + [self.excludeAnalyticsSwitch addTarget:self + action:@selector(excludeAnalyticsToggled:) + forControlEvents:UIControlEventValueChanged]; + + self.excludeFirebaseAnalyticsSwitch.on = defaults.flex_exportExcludeFirebaseAnalytics; + [self.excludeFirebaseAnalyticsSwitch addTarget:self + action:@selector(excludeFirebaseAnalyticsToggled:) + forControlEvents:UIControlEventValueChanged]; } -- (void)setCacheLimitValue:(float)cacheLimitValue { +- (void)setCacheLimitValue:(float)cacheLimitValue +{ _cacheLimitValue = cacheLimitValue; self.cacheLimitLabel.text = self.cacheLimitCellTitle; [FLEXNetworkRecorder.defaultRecorder setResponseCacheByteLimit:cacheLimitValue]; } -- (NSString *)cacheLimitCellTitle { +- (NSString *)cacheLimitCellTitle +{ NSInteger cacheLimit = self.cacheLimitValue; NSInteger limitInMB = round(cacheLimit / (1024 * 1024)); return [NSString stringWithFormat:@"Cache Limit (%@ MB)", @(limitInMB)]; @@ -87,64 +110,103 @@ - (NSString *)cacheLimitCellTitle { #pragma mark - Settings Actions -- (void)networkDebuggingToggled:(UISwitch *)sender { +- (void)networkDebuggingToggled:(UISwitch *)sender +{ FLEXNetworkObserver.enabled = sender.isOn; } -- (void)cacheMediaResponsesToggled:(UISwitch *)sender { +- (void)cacheMediaResponsesToggled:(UISwitch *)sender +{ FLEXNetworkRecorder.defaultRecorder.shouldCacheMediaResponses = sender.isOn; } -- (void)jsonViewerSettingToggled:(UISwitch *)sender { +- (void)jsonViewerSettingToggled:(UISwitch *)sender +{ [NSUserDefaults.standardUserDefaults flex_toggleBoolForKey:kFLEXDefaultsRegisterJSONExplorerKey]; } -- (void)cacheLimitAdjusted:(UISlider *)sender { +- (void)cacheLimitAdjusted:(UISlider *)sender +{ self.cacheLimitValue = sender.value; } +- (void)excludeImagesToggled:(UISwitch *)sender +{ + [NSUserDefaults standardUserDefaults].flex_exportExcludeImages = sender.isOn; +} + +- (void)excludeAnalyticsToggled:(UISwitch *)sender +{ + [NSUserDefaults standardUserDefaults].flex_exportExcludeAnalytics = sender.isOn; +} + +- (void)excludeFirebaseAnalyticsToggled:(UISwitch *)sender +{ + [NSUserDefaults standardUserDefaults].flex_exportExcludeFirebaseAnalytics = sender.isOn; +} + #pragma mark - Table View Data Source -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { - return 2; +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return 3; } -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ switch (section) { - case 0: return 5; - case 1: return self.hostDenylist.count; - default: return 0; + case 0: + return 5; + case 1: + return 3; + case 2: + return self.hostDenylist.count; + default: + return 0; } } -- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ switch (section) { - case 0: return @"General"; - case 1: return @"Host Denylist"; - default: return nil; + case 0: + return @"General"; + case 1: + return @"Export Filters"; + case 2: + return @"Host Denylist"; + default: + return nil; } } -- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ if (section == 0) { return @"By default, JSON is rendered in a webview. Turn on " - "\"View JSON as a dictionary/array\" to convert JSON payloads " - "to objects and view them in an object explorer. " - "This setting requires a restart of the app."; + "\"View JSON as a dictionary/array\" to convert JSON payloads " + "to objects and view them in an object explorer. " + "This setting requires a restart of the app."; + } + if (section == 1) { + return @"These filters apply when exporting requests. " + "Analytics includes 80+ tracking SDKs (Adjust, Facebook, AppsFlyer, etc.). " + "Firebase Analytics excludes FA but keeps Remote Config."; } - + return nil; } -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ UITableViewCell *cell = [self.tableView - dequeueReusableCellWithIdentifier:kFLEXDefaultCell forIndexPath:indexPath - ]; - + dequeueReusableCellWithIdentifier:kFLEXDefaultCell + forIndexPath:indexPath]; + cell.accessoryView = nil; cell.textLabel.textColor = FLEXColor.primaryTextColor; - + switch (indexPath.section) { // Settings case 0: { @@ -170,37 +232,55 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N self.cacheLimitLabel = cell.textLabel; [self.cacheLimitSlider removeFromSuperview]; [cell.contentView addSubview:self.cacheLimitSlider]; - + CGRect container = cell.contentView.frame; UISlider *slider = self.cacheLimitSlider; [slider sizeToFit]; - + CGFloat sliderWidth = 150.f; CGFloat sliderOriginY = FLEXFloor((container.size.height - slider.frame.size.height) / 2.0); CGFloat sliderOriginX = CGRectGetMaxX(container) - sliderWidth - tableView.separatorInset.left; self.cacheLimitSlider.frame = CGRectMake( - sliderOriginX, sliderOriginY, sliderWidth, slider.frame.size.height - ); - + sliderOriginX, sliderOriginY, sliderWidth, slider.frame.size.height); + // Make wider, keep in middle of cell, keep to trailing edge of cell self.cacheLimitSlider.autoresizingMask = ({ UIViewAutoresizingFlexibleWidth | - UIViewAutoresizingFlexibleLeftMargin | - UIViewAutoresizingFlexibleTopMargin | - UIViewAutoresizingFlexibleBottomMargin; + UIViewAutoresizingFlexibleLeftMargin | + UIViewAutoresizingFlexibleTopMargin | + UIViewAutoresizingFlexibleBottomMargin; }); break; } - + break; } - - // Denylist entries + + // Export Filters case 1: { + switch (indexPath.row) { + case 0: + cell.textLabel.text = @"Exclude Images"; + cell.accessoryView = self.excludeImagesSwitch; + break; + case 1: + cell.textLabel.text = @"Exclude Analytics SDKs"; + cell.accessoryView = self.excludeAnalyticsSwitch; + break; + case 2: + cell.textLabel.text = @"Exclude Firebase Analytics"; + cell.accessoryView = self.excludeFirebaseAnalyticsSwitch; + break; + } + break; + } + + // Denylist entries + case 2: { cell.textLabel.text = self.hostDenylist[indexPath.row]; break; } - + default: @throw NSInternalInconsistencyException; break; @@ -211,43 +291,49 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N #pragma mark - Table View Delegate -- (BOOL)tableView:(UITableView *)tableView shouldHighlightRowAtIndexPath:(NSIndexPath *)ip { - // Can only select the "Reset Host Denylist" row - return ip.section == 0 && ip.row == 2; +- (BOOL)tableView:(UITableView *)tableView shouldHighlightRowAtIndexPath:(NSIndexPath *)ip +{ + // Can only select the "Reset Host Denylist" row (was row 2, now row 3 since we added View JSON) + return ip.section == 0 && ip.row == 3; } -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ [tableView deselectRowAtIndexPath:indexPath animated:YES]; - - [FLEXAlert makeAlert:^(FLEXAlert *make) { - make.title(@"Reset Host Denylist"); - make.message(@"You cannot undo this action. Are you sure?"); - make.button(@"Reset").destructiveStyle().handler(^(NSArray *strings) { - self.hostDenylist = nil; - [FLEXNetworkRecorder.defaultRecorder.hostDenylist removeAllObjects]; - [FLEXNetworkRecorder.defaultRecorder synchronizeDenylist]; - [self.tableView deleteSections: - [NSIndexSet indexSetWithIndex:1] - withRowAnimation:UITableViewRowAnimationAutomatic]; - }); - make.button(@"Cancel").cancelStyle(); - } showFrom:self]; -} - -- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { - return indexPath.section == 1; + + [FLEXAlert + makeAlert:^(FLEXAlert *make) { + make.title(@"Reset Host Denylist"); + make.message(@"You cannot undo this action. Are you sure?"); + make.button(@"Reset").destructiveStyle().handler(^(NSArray *strings) { + self.hostDenylist = nil; + [FLEXNetworkRecorder.defaultRecorder.hostDenylist removeAllObjects]; + [FLEXNetworkRecorder.defaultRecorder synchronizeDenylist]; + [self.tableView deleteSections: + [NSIndexSet indexSetWithIndex:1] + withRowAnimation:UITableViewRowAnimationAutomatic]; + }); + make.button(@"Cancel").cancelStyle(); + } + showFrom:self]; +} + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + return indexPath.section == 2; } - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)style -forRowAtIndexPath:(NSIndexPath *)indexPath { + forRowAtIndexPath:(NSIndexPath *)indexPath +{ NSParameterAssert(style == UITableViewCellEditingStyleDelete); - + NSString *host = self.hostDenylist[indexPath.row]; [self.hostDenylist removeObjectAtIndex:indexPath.row]; [FLEXNetworkRecorder.defaultRecorder.hostDenylist removeObject:host]; [FLEXNetworkRecorder.defaultRecorder synchronizeDenylist]; - - [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; + + [tableView deleteRowsAtIndexPaths:@[ indexPath ] withRowAnimation:UITableViewRowAnimationAutomatic]; } @end diff --git a/Classes/Utility/Categories/NSUserDefaults+FLEX.h b/Classes/Utility/Categories/NSUserDefaults+FLEX.h index b17bcb4b49..0b97d84d87 100644 --- a/Classes/Utility/Categories/NSUserDefaults+FLEX.h +++ b/Classes/Utility/Categories/NSUserDefaults+FLEX.h @@ -9,18 +9,18 @@ #import // Only use these if the getters and setters aren't good enough for whatever reason -extern NSString * const kFLEXDefaultsToolbarTopMarginKey; -extern NSString * const kFLEXDefaultsiOSPersistentOSLogKey; -extern NSString * const kFLEXDefaultsHidePropertyIvarsKey; -extern NSString * const kFLEXDefaultsHidePropertyMethodsKey; -extern NSString * const kFLEXDefaultsHidePrivateMethodsKey; -extern NSString * const kFLEXDefaultsShowMethodOverridesKey; -extern NSString * const kFLEXDefaultsHideVariablePreviewsKey; -extern NSString * const kFLEXDefaultsNetworkObserverEnabledKey; -extern NSString * const kFLEXDefaultsNetworkHostDenylistKey; -extern NSString * const kFLEXDefaultsDisableOSLogForceASLKey; -extern NSString * const kFLEXDefaultsAPNSCaptureEnabledKey; -extern NSString * const kFLEXDefaultsRegisterJSONExplorerKey; +extern NSString *const kFLEXDefaultsToolbarTopMarginKey; +extern NSString *const kFLEXDefaultsiOSPersistentOSLogKey; +extern NSString *const kFLEXDefaultsHidePropertyIvarsKey; +extern NSString *const kFLEXDefaultsHidePropertyMethodsKey; +extern NSString *const kFLEXDefaultsHidePrivateMethodsKey; +extern NSString *const kFLEXDefaultsShowMethodOverridesKey; +extern NSString *const kFLEXDefaultsHideVariablePreviewsKey; +extern NSString *const kFLEXDefaultsNetworkObserverEnabledKey; +extern NSString *const kFLEXDefaultsNetworkHostDenylistKey; +extern NSString *const kFLEXDefaultsDisableOSLogForceASLKey; +extern NSString *const kFLEXDefaultsAPNSCaptureEnabledKey; +extern NSString *const kFLEXDefaultsRegisterJSONExplorerKey; /// All BOOL preferences are NO by default @interface NSUserDefaults (FLEX) @@ -51,4 +51,10 @@ extern NSString * const kFLEXDefaultsRegisterJSONExplorerKey; @property (nonatomic) BOOL flex_explorerShowsMethodOverrides; @property (nonatomic) BOOL flex_explorerHidesVariablePreviews; +#pragma mark - Export Filters + +@property (nonatomic) BOOL flex_exportExcludeImages; +@property (nonatomic) BOOL flex_exportExcludeAnalytics; +@property (nonatomic) BOOL flex_exportExcludeFirebaseAnalytics; + @end diff --git a/Classes/Utility/Categories/NSUserDefaults+FLEX.m b/Classes/Utility/Categories/NSUserDefaults+FLEX.m index a15ac51086..1406855621 100644 --- a/Classes/Utility/Categories/NSUserDefaults+FLEX.m +++ b/Classes/Utility/Categories/NSUserDefaults+FLEX.m @@ -8,24 +8,26 @@ #import "NSUserDefaults+FLEX.h" -NSString * const kFLEXDefaultsToolbarTopMarginKey = @"com.flex.FLEXToolbar.topMargin"; -NSString * const kFLEXDefaultsiOSPersistentOSLogKey = @"com.flipborad.flex.enable_persistent_os_log"; -NSString * const kFLEXDefaultsHidePropertyIvarsKey = @"com.flipboard.FLEX.hide_property_ivars"; -NSString * const kFLEXDefaultsHidePropertyMethodsKey = @"com.flipboard.FLEX.hide_property_methods"; -NSString * const kFLEXDefaultsHidePrivateMethodsKey = @"com.flipboard.FLEX.hide_private_or_namespaced_methods"; -NSString * const kFLEXDefaultsShowMethodOverridesKey = @"com.flipboard.FLEX.show_method_overrides"; -NSString * const kFLEXDefaultsHideVariablePreviewsKey = @"com.flipboard.FLEX.hide_variable_previews"; -NSString * const kFLEXDefaultsNetworkObserverEnabledKey = @"com.flex.FLEXNetworkObserver.enableOnLaunch"; -NSString * const kFLEXDefaultsNetworkObserverLastModeKey = @"com.flex.FLEXNetworkObserver.lastMode"; -NSString * const kFLEXDefaultsNetworkHostDenylistKey = @"com.flipboard.FLEX.network_host_denylist"; -NSString * const kFLEXDefaultsDisableOSLogForceASLKey = @"com.flipboard.FLEX.try_disable_os_log"; -NSString * const kFLEXDefaultsAPNSCaptureEnabledKey = @"com.flipboard.FLEX.capture_apns"; -NSString * const kFLEXDefaultsRegisterJSONExplorerKey = @"com.flipboard.FLEX.view_json_as_object"; - -#define FLEXDefaultsPathForFile(name) ({ \ - NSArray *paths = NSSearchPathForDirectoriesInDomains( \ - NSLibraryDirectory, NSUserDomainMask, YES \ - ); \ +NSString *const kFLEXDefaultsToolbarTopMarginKey = @"com.flex.FLEXToolbar.topMargin"; +NSString *const kFLEXDefaultsiOSPersistentOSLogKey = @"com.flipborad.flex.enable_persistent_os_log"; +NSString *const kFLEXDefaultsHidePropertyIvarsKey = @"com.flipboard.FLEX.hide_property_ivars"; +NSString *const kFLEXDefaultsHidePropertyMethodsKey = @"com.flipboard.FLEX.hide_property_methods"; +NSString *const kFLEXDefaultsHidePrivateMethodsKey = @"com.flipboard.FLEX.hide_private_or_namespaced_methods"; +NSString *const kFLEXDefaultsShowMethodOverridesKey = @"com.flipboard.FLEX.show_method_overrides"; +NSString *const kFLEXDefaultsHideVariablePreviewsKey = @"com.flipboard.FLEX.hide_variable_previews"; +NSString *const kFLEXDefaultsNetworkObserverEnabledKey = @"com.flex.FLEXNetworkObserver.enableOnLaunch"; +NSString *const kFLEXDefaultsNetworkObserverLastModeKey = @"com.flex.FLEXNetworkObserver.lastMode"; +NSString *const kFLEXDefaultsNetworkHostDenylistKey = @"com.flipboard.FLEX.network_host_denylist"; +NSString *const kFLEXDefaultsDisableOSLogForceASLKey = @"com.flipboard.FLEX.try_disable_os_log"; +NSString *const kFLEXDefaultsAPNSCaptureEnabledKey = @"com.flipboard.FLEX.capture_apns"; +NSString *const kFLEXDefaultsRegisterJSONExplorerKey = @"com.flipboard.FLEX.view_json_as_object"; +NSString *const kFLEXDefaultsExportExcludeImagesKey = @"com.flex.export.exclude_images"; +NSString *const kFLEXDefaultsExportExcludeAnalyticsKey = @"com.flex.export.exclude_analytics"; +NSString *const kFLEXDefaultsExportExcludeFirebaseAnalyticsKey = @"com.flex.export.exclude_firebase_analytics"; + +#define FLEXDefaultsPathForFile(name) ({ \ + NSArray *paths = NSSearchPathForDirectoriesInDomains( \ + NSLibraryDirectory, NSUserDomainMask, YES); \ [paths[0] stringByAppendingPathComponent:@"Preferences"]; \ }) @@ -34,166 +36,215 @@ @implementation NSUserDefaults (FLEX) #pragma mark Internal /// @param filename the name of a plist file without any extension -- (NSString *)flex_defaultsPathForFile:(NSString *)filename { +- (NSString *)flex_defaultsPathForFile:(NSString *)filename +{ filename = [filename stringByAppendingPathExtension:@"plist"]; - + NSArray *paths = NSSearchPathForDirectoriesInDomains( - NSLibraryDirectory, NSUserDomainMask, YES - ); + NSLibraryDirectory, NSUserDomainMask, YES); NSString *preferences = [paths[0] stringByAppendingPathComponent:@"Preferences"]; return [preferences stringByAppendingPathComponent:filename]; } #pragma mark Helper -- (void)flex_toggleBoolForKey:(NSString *)key { +- (void)flex_toggleBoolForKey:(NSString *)key +{ [self setBool:![self boolForKey:key] forKey:key]; [NSNotificationCenter.defaultCenter postNotificationName:key object:nil]; } #pragma mark Misc -- (double)flex_toolbarTopMargin { +- (double)flex_toolbarTopMargin +{ if ([self objectForKey:kFLEXDefaultsToolbarTopMarginKey]) { return [self doubleForKey:kFLEXDefaultsToolbarTopMarginKey]; } - + return 100; } -- (void)setFlex_toolbarTopMargin:(double)margin { +- (void)setFlex_toolbarTopMargin:(double)margin +{ [self setDouble:margin forKey:kFLEXDefaultsToolbarTopMarginKey]; } -- (BOOL)flex_networkObserverEnabled { +- (BOOL)flex_networkObserverEnabled +{ return [self boolForKey:kFLEXDefaultsNetworkObserverEnabledKey]; } -- (void)setFlex_networkObserverEnabled:(BOOL)enabled { +- (void)setFlex_networkObserverEnabled:(BOOL)enabled +{ [self setBool:enabled forKey:kFLEXDefaultsNetworkObserverEnabledKey]; } -- (NSArray *)flex_networkHostDenylist { - return [NSArray arrayWithContentsOfFile:[ - self flex_defaultsPathForFile:kFLEXDefaultsNetworkHostDenylistKey - ]] ?: @[]; +- (NSArray *)flex_networkHostDenylist +{ + return [NSArray arrayWithContentsOfFile:[self flex_defaultsPathForFile:kFLEXDefaultsNetworkHostDenylistKey]] ?: @[]; } -- (void)setFlex_networkHostDenylist:(NSArray *)denylist { +- (void)setFlex_networkHostDenylist:(NSArray *)denylist +{ NSParameterAssert(denylist); - [denylist writeToFile:[ - self flex_defaultsPathForFile:kFLEXDefaultsNetworkHostDenylistKey - ] atomically:YES]; + [denylist writeToFile:[self flex_defaultsPathForFile:kFLEXDefaultsNetworkHostDenylistKey] atomically:YES]; } -- (BOOL)flex_registerDictionaryJSONViewerOnLaunch { +- (BOOL)flex_registerDictionaryJSONViewerOnLaunch +{ return [self boolForKey:kFLEXDefaultsRegisterJSONExplorerKey]; } -- (void)setFlex_registerDictionaryJSONViewerOnLaunch:(BOOL)enable { +- (void)setFlex_registerDictionaryJSONViewerOnLaunch:(BOOL)enable +{ [self setBool:enable forKey:kFLEXDefaultsRegisterJSONExplorerKey]; } -- (NSInteger)flex_lastNetworkObserverMode { +- (NSInteger)flex_lastNetworkObserverMode +{ return [self integerForKey:kFLEXDefaultsNetworkObserverLastModeKey]; } -- (void)setFlex_lastNetworkObserverMode:(NSInteger)mode { +- (void)setFlex_lastNetworkObserverMode:(NSInteger)mode +{ [self setInteger:mode forKey:kFLEXDefaultsNetworkObserverLastModeKey]; } #pragma mark System Log -- (BOOL)flex_disableOSLog { +- (BOOL)flex_disableOSLog +{ return [self boolForKey:kFLEXDefaultsDisableOSLogForceASLKey]; } -- (void)setFlex_disableOSLog:(BOOL)disable { +- (void)setFlex_disableOSLog:(BOOL)disable +{ [self setBool:disable forKey:kFLEXDefaultsDisableOSLogForceASLKey]; } -- (BOOL)flex_cacheOSLogMessages { +- (BOOL)flex_cacheOSLogMessages +{ return [self boolForKey:kFLEXDefaultsiOSPersistentOSLogKey]; } -- (void)setFlex_cacheOSLogMessages:(BOOL)cache { +- (void)setFlex_cacheOSLogMessages:(BOOL)cache +{ [self setBool:cache forKey:kFLEXDefaultsiOSPersistentOSLogKey]; [NSNotificationCenter.defaultCenter postNotificationName:kFLEXDefaultsiOSPersistentOSLogKey - object:nil - ]; + object:nil]; } #pragma mark Push Notifications -- (BOOL)flex_enableAPNSCapture { +- (BOOL)flex_enableAPNSCapture +{ return [self boolForKey:kFLEXDefaultsAPNSCaptureEnabledKey]; } -- (void)setFlex_enableAPNSCapture:(BOOL)enable { +- (void)setFlex_enableAPNSCapture:(BOOL)enable +{ [self setBool:enable forKey:kFLEXDefaultsAPNSCaptureEnabledKey]; } #pragma mark Object Explorer -- (BOOL)flex_explorerHidesPropertyIvars { +- (BOOL)flex_explorerHidesPropertyIvars +{ return [self boolForKey:kFLEXDefaultsHidePropertyIvarsKey]; } -- (void)setFlex_explorerHidesPropertyIvars:(BOOL)hide { +- (void)setFlex_explorerHidesPropertyIvars:(BOOL)hide +{ [self setBool:hide forKey:kFLEXDefaultsHidePropertyIvarsKey]; [NSNotificationCenter.defaultCenter postNotificationName:kFLEXDefaultsHidePropertyIvarsKey - object:nil - ]; + object:nil]; } -- (BOOL)flex_explorerHidesPropertyMethods { +- (BOOL)flex_explorerHidesPropertyMethods +{ return [self boolForKey:kFLEXDefaultsHidePropertyMethodsKey]; } -- (void)setFlex_explorerHidesPropertyMethods:(BOOL)hide { +- (void)setFlex_explorerHidesPropertyMethods:(BOOL)hide +{ [self setBool:hide forKey:kFLEXDefaultsHidePropertyMethodsKey]; [NSNotificationCenter.defaultCenter postNotificationName:kFLEXDefaultsHidePropertyMethodsKey - object:nil - ]; + object:nil]; } -- (BOOL)flex_explorerHidesPrivateMethods { +- (BOOL)flex_explorerHidesPrivateMethods +{ return [self boolForKey:kFLEXDefaultsHidePrivateMethodsKey]; } -- (void)setFlex_explorerHidesPrivateMethods:(BOOL)show { +- (void)setFlex_explorerHidesPrivateMethods:(BOOL)show +{ [self setBool:show forKey:kFLEXDefaultsHidePrivateMethodsKey]; [NSNotificationCenter.defaultCenter - postNotificationName:kFLEXDefaultsHidePrivateMethodsKey - object:nil - ]; + postNotificationName:kFLEXDefaultsHidePrivateMethodsKey + object:nil]; } -- (BOOL)flex_explorerShowsMethodOverrides { +- (BOOL)flex_explorerShowsMethodOverrides +{ return [self boolForKey:kFLEXDefaultsShowMethodOverridesKey]; } -- (void)setFlex_explorerShowsMethodOverrides:(BOOL)show { +- (void)setFlex_explorerShowsMethodOverrides:(BOOL)show +{ [self setBool:show forKey:kFLEXDefaultsShowMethodOverridesKey]; [NSNotificationCenter.defaultCenter - postNotificationName:kFLEXDefaultsShowMethodOverridesKey - object:nil - ]; + postNotificationName:kFLEXDefaultsShowMethodOverridesKey + object:nil]; } -- (BOOL)flex_explorerHidesVariablePreviews { +- (BOOL)flex_explorerHidesVariablePreviews +{ return [self boolForKey:kFLEXDefaultsHideVariablePreviewsKey]; } -- (void)setFlex_explorerHidesVariablePreviews:(BOOL)hide { +- (void)setFlex_explorerHidesVariablePreviews:(BOOL)hide +{ [self setBool:hide forKey:kFLEXDefaultsHideVariablePreviewsKey]; [NSNotificationCenter.defaultCenter postNotificationName:kFLEXDefaultsHideVariablePreviewsKey - object:nil - ]; + object:nil]; +} + +#pragma mark Export Filters + +- (BOOL)flex_exportExcludeImages +{ + return [self boolForKey:kFLEXDefaultsExportExcludeImagesKey]; +} + +- (void)setFlex_exportExcludeImages:(BOOL)exclude +{ + [self setBool:exclude forKey:kFLEXDefaultsExportExcludeImagesKey]; +} + +- (BOOL)flex_exportExcludeAnalytics +{ + return [self boolForKey:kFLEXDefaultsExportExcludeAnalyticsKey]; +} + +- (void)setFlex_exportExcludeAnalytics:(BOOL)exclude +{ + [self setBool:exclude forKey:kFLEXDefaultsExportExcludeAnalyticsKey]; +} + +- (BOOL)flex_exportExcludeFirebaseAnalytics +{ + return [self boolForKey:kFLEXDefaultsExportExcludeFirebaseAnalyticsKey]; +} + +- (void)setFlex_exportExcludeFirebaseAnalytics:(BOOL)exclude +{ + [self setBool:exclude forKey:kFLEXDefaultsExportExcludeFirebaseAnalyticsKey]; } @end diff --git a/FLEX.xcodeproj/project.pbxproj b/FLEX.xcodeproj/project.pbxproj index 462efb419c..eec88aec04 100644 --- a/FLEX.xcodeproj/project.pbxproj +++ b/FLEX.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 04F1CA191C137CF1000A52B0 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 04F1CA181C137CF1000A52B0 /* LICENSE */; }; 1C27A8B91F0E5A0400F0D02D /* FLEXTestsMethodsList.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C27A8B81F0E5A0400F0D02D /* FLEXTestsMethodsList.m */; }; 1C27A8BB1F0E5A0400F0D02D /* FLEX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4C941F1B5B20570088C3F2 /* FLEX.framework */; }; + 1E0E7518C6B9B89C64DB7174 /* FLEXNetworkExporter.h in Headers */ = {isa = PBXBuildFile; fileRef = E3F94D34B345EEDBDAC4F753 /* FLEXNetworkExporter.h */; }; 222C88221C7339DC007CA15F /* FLEXRealmDefines.h in Headers */ = {isa = PBXBuildFile; fileRef = 222C88211C7339DC007CA15F /* FLEXRealmDefines.h */; }; 224D49A81C673AB5000EAB86 /* FLEXRealmDatabaseManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 224D49A41C673AB5000EAB86 /* FLEXRealmDatabaseManager.h */; }; 224D49A91C673AB5000EAB86 /* FLEXRealmDatabaseManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 224D49A51C673AB5000EAB86 /* FLEXRealmDatabaseManager.m */; }; @@ -126,6 +127,7 @@ 779B1EDA1C0C4D7C001F5E49 /* FLEXTableListViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 779B1ECC1C0C4D7C001F5E49 /* FLEXTableListViewController.h */; }; 779B1EDB1C0C4D7C001F5E49 /* FLEXTableListViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 779B1ECD1C0C4D7C001F5E49 /* FLEXTableListViewController.m */; }; 779B1EDD1C0C4EAD001F5E49 /* libsqlite3.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 779B1EDC1C0C4EAD001F5E49 /* libsqlite3.dylib */; }; + 7868C677D17F931D557187B9 /* FLEXNetworkExporter.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C5398E0B292F90F21F13CF8 /* FLEXNetworkExporter.m */; }; 942DCD871BAE0CA300DB5DC2 /* FLEXKeyboardShortcutManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 942DCD831BAE0AD300DB5DC2 /* FLEXKeyboardShortcutManager.m */; }; 94A515141C4CA1C00063292F /* FLEXManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 94A515131C4CA1C00063292F /* FLEXManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; 94A515171C4CA1D70063292F /* FLEXManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 94A515151C4CA1D70063292F /* FLEXManager.m */; }; @@ -394,6 +396,7 @@ 1C27A8B61F0E5A0300F0D02D /* FLEXTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FLEXTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1C27A8B81F0E5A0400F0D02D /* FLEXTestsMethodsList.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLEXTestsMethodsList.m; sourceTree = ""; }; 1C27A8BA1F0E5A0400F0D02D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1C5398E0B292F90F21F13CF8 /* FLEXNetworkExporter.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = FLEXNetworkExporter.m; sourceTree = ""; }; 222C88211C7339DC007CA15F /* FLEXRealmDefines.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FLEXRealmDefines.h; sourceTree = ""; }; 224D49A41C673AB5000EAB86 /* FLEXRealmDatabaseManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FLEXRealmDatabaseManager.h; sourceTree = ""; }; 224D49A51C673AB5000EAB86 /* FLEXRealmDatabaseManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLEXRealmDatabaseManager.m; sourceTree = ""; }; @@ -763,6 +766,7 @@ C3F977802311B38F0032776D /* NSString+ObjcRuntime.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+ObjcRuntime.m"; sourceTree = ""; }; C3F977812311B38F0032776D /* NSObject+FLEX_Reflection.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+FLEX_Reflection.h"; sourceTree = ""; }; C3F977822311B38F0032776D /* NSObject+FLEX_Reflection.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+FLEX_Reflection.m"; sourceTree = ""; }; + E3F94D34B345EEDBDAC4F753 /* FLEXNetworkExporter.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = FLEXNetworkExporter.h; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1028,6 +1032,8 @@ 2EF6B04B1D494BE50006BDA5 /* FLEXNetworkCurlLogger.m */, C3DBFD0926CE2FAF00E0466A /* OSCache */, 3A4C94C11B5B21410088C3F2 /* PonyDebugger */, + E3F94D34B345EEDBDAC4F753 /* FLEXNetworkExporter.h */, + 1C5398E0B292F90F21F13CF8 /* FLEXNetworkExporter.m */, ); path = Network; sourceTree = ""; @@ -1712,6 +1718,7 @@ 3A4C94E11B5B21410088C3F2 /* FLEXResources.h in Headers */, C3EB6F8E242E9C83006EA386 /* FLEXRuntimeExporter.h in Headers */, 779B1ED81C0C4D7C001F5E49 /* FLEXTableLeftCell.h in Headers */, + 1E0E7518C6B9B89C64DB7174 /* FLEXNetworkExporter.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2002,6 +2009,7 @@ C3511B9222D7C99E0057BAB7 /* FLEXGlobalsSection.m in Sources */, C32A19632317378C00EB02AC /* FLEXDefaultsContentSection.m in Sources */, C3EE76C022DFC63600EC0AA0 /* FLEXScopeCarousel.m in Sources */, + 7868C677D17F931D557187B9 /* FLEXNetworkExporter.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From c56f933a613c3eb26a7956219bbf0c75c80e32cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?enes=20o=CC=88ztu=CC=88rk?= Date: Sun, 4 Jan 2026 23:34:57 +0300 Subject: [PATCH 2/3] fix(network): Replace VLA with fixed-size buffer to fix compilation error --- Classes/Network/FLEXNetworkTransaction.m | 126 +++++++++++++---------- 1 file changed, 73 insertions(+), 53 deletions(-) diff --git a/Classes/Network/FLEXNetworkTransaction.m b/Classes/Network/FLEXNetworkTransaction.m index 7eaf08de46..c49a117d73 100644 --- a/Classes/Network/FLEXNetworkTransaction.m +++ b/Classes/Network/FLEXNetworkTransaction.m @@ -13,25 +13,26 @@ @implementation FLEXNetworkTransaction -+ (NSString *)readableStringFromTransactionState:(FLEXNetworkTransactionState)state { ++ (NSString *)readableStringFromTransactionState:(FLEXNetworkTransactionState)state +{ NSString *readableString = nil; switch (state) { case FLEXNetworkTransactionStateUnstarted: readableString = @"Unstarted"; break; - + case FLEXNetworkTransactionStateAwaitingResponse: readableString = @"Awaiting Response"; break; - + case FLEXNetworkTransactionStateReceivingData: readableString = @"Receiving Data"; break; - + case FLEXNetworkTransactionStateFinished: readableString = @"Finished"; break; - + case FLEXNetworkTransactionStateFailed: readableString = @"Failed"; break; @@ -39,31 +40,37 @@ + (NSString *)readableStringFromTransactionState:(FLEXNetworkTransactionState)st return readableString; } -+ (instancetype)withStartTime:(NSDate *)startTime { ++ (instancetype)withStartTime:(NSDate *)startTime +{ FLEXNetworkTransaction *transaction = [self new]; transaction->_startTime = startTime; return transaction; } -- (NSString *)timestampStringFromRequestDate:(NSDate *)date { +- (NSString *)timestampStringFromRequestDate:(NSDate *)date +{ return [NSDateFormatter flex_stringFrom:date format:FLEXDateFormatPreciseClock]; } -- (void)setState:(FLEXNetworkTransactionState)transactionState { +- (void)setState:(FLEXNetworkTransactionState)transactionState +{ _state = transactionState; // Reset bottom description _tertiaryDescription = nil; } -- (BOOL)displayAsError { +- (BOOL)displayAsError +{ return _error != nil; } -- (NSString *)copyString { +- (NSString *)copyString +{ return nil; } -- (BOOL)matchesQuery:(NSString *)filterString { +- (BOOL)matchesQuery:(NSString *)filterString +{ return NO; } @@ -76,62 +83,66 @@ @interface FLEXURLTransaction () @implementation FLEXURLTransaction -+ (instancetype)withRequest:(NSURLRequest *)request startTime:(NSDate *)startTime { ++ (instancetype)withRequest:(NSURLRequest *)request startTime:(NSDate *)startTime +{ FLEXURLTransaction *transaction = [self withStartTime:startTime]; transaction->_request = request; return transaction; } -- (NSString *)primaryDescription { +- (NSString *)primaryDescription +{ if (!_primaryDescription) { NSString *name = self.request.URL.lastPathComponent; if (!name.length) { name = @"/"; } - + if (_request.URL.query) { name = [name stringByAppendingFormat:@"?%@", self.request.URL.query]; } - + _primaryDescription = name; } - + return _primaryDescription; } -- (NSString *)secondaryDescription { +- (NSString *)secondaryDescription +{ if (!_secondaryDescription) { NSMutableArray *mutablePathComponents = self.request.URL.pathComponents.mutableCopy; if (mutablePathComponents.count > 0) { [mutablePathComponents removeLastObject]; } - + NSString *path = self.request.URL.host; for (NSString *pathComponent in mutablePathComponents) { path = [path stringByAppendingPathComponent:pathComponent]; } - + _secondaryDescription = path; } - + return _secondaryDescription; } -- (NSString *)tertiaryDescription { +- (NSString *)tertiaryDescription +{ if (!_tertiaryDescription) { NSMutableArray *detailComponents = [NSMutableArray new]; - + NSString *timestamp = [self timestampStringFromRequestDate:self.startTime]; if (timestamp.length > 0) { [detailComponents addObject:timestamp]; } - + // Omit method for GET (assumed as default) NSString *httpMethod = self.request.HTTPMethod; if (httpMethod.length > 0) { [detailComponents addObject:httpMethod]; } - + if (self.state == FLEXNetworkTransactionStateFinished || self.state == FLEXNetworkTransactionStateFailed) { [detailComponents addObjectsFromArray:self.details]; } else { @@ -139,18 +150,20 @@ - (NSString *)tertiaryDescription { NSString *state = [self.class readableStringFromTransactionState:self.state]; [detailComponents addObject:state]; } - + _tertiaryDescription = [detailComponents componentsJoinedByString:@" ・ "]; } - + return _tertiaryDescription; } -- (NSString *)copyString { +- (NSString *)copyString +{ return self.request.URL.absoluteString; } -- (BOOL)matchesQuery:(NSString *)filterString { +- (BOOL)matchesQuery:(NSString *)filterString +{ return [self.request.URL.absoluteString localizedCaseInsensitiveContainsString:filterString]; } @@ -162,36 +175,39 @@ @interface FLEXHTTPTransaction () @implementation FLEXHTTPTransaction -+ (instancetype)request:(NSURLRequest *)request identifier:(NSString *)requestID { ++ (instancetype)request:(NSURLRequest *)request identifier:(NSString *)requestID +{ FLEXHTTPTransaction *httpt = [self withRequest:request startTime:NSDate.date]; httpt->_requestID = requestID; return httpt; } -- (NSString *)description { +- (NSString *)description +{ NSString *description = [super description]; - + description = [description stringByAppendingFormat:@" id = %@;", self.requestID]; description = [description stringByAppendingFormat:@" url = %@;", self.request.URL]; description = [description stringByAppendingFormat:@" duration = %f;", self.duration]; description = [description stringByAppendingFormat:@" receivedDataLength = %lld", self.receivedDataLength]; - + return description; } -- (NSData *)cachedRequestBody { +- (NSData *)cachedRequestBody +{ if (!_cachedRequestBody) { if (self.request.HTTPBody != nil) { _cachedRequestBody = self.request.HTTPBody; } else if ([self.request.HTTPBodyStream conformsToProtocol:@protocol(NSCopying)]) { NSInputStream *bodyStream = [self.request.HTTPBodyStream copy]; - const NSUInteger bufferSize = 1024; - uint8_t buffer[bufferSize]; +#define BUFFER_SIZE 1024 + uint8_t buffer[BUFFER_SIZE]; NSMutableData *data = [NSMutableData new]; [bodyStream open]; NSInteger readBytes = 0; do { - readBytes = [bodyStream read:buffer maxLength:bufferSize]; + readBytes = [bodyStream read:buffer maxLength:BUFFER_SIZE]; [data appendBytes:buffer length:readBytes]; } while (readBytes > 0); [bodyStream close]; @@ -201,9 +217,10 @@ - (NSData *)cachedRequestBody { return _cachedRequestBody; } -- (NSArray *)detailString { +- (NSArray *)detailString +{ NSMutableArray *detailComponents = [NSMutableArray new]; - + NSString *statusCodeString = [FLEXUtility statusCodeStringFromURLResponse:self.response]; if (statusCodeString.length > 0) { [detailComponents addObject:statusCodeString]; @@ -212,8 +229,7 @@ - (NSArray *)detailString { if (self.receivedDataLength > 0) { NSString *responseSize = [NSByteCountFormatter stringFromByteCount:self.receivedDataLength - countStyle:NSByteCountFormatterCountStyleBinary - ]; + countStyle:NSByteCountFormatterCountStyleBinary]; [detailComponents addObject:responseSize]; } @@ -221,11 +237,12 @@ - (NSArray *)detailString { NSString *latency = [FLEXUtility stringFromRequestDuration:self.latency]; NSString *duration = [NSString stringWithFormat:@"%@ (%@)", totalDuration, latency]; [detailComponents addObject:duration]; - + return detailComponents; } -- (BOOL)displayAsError { +- (BOOL)displayAsError +{ return [FLEXUtility isErrorStatusCodeFromURLResponse:self.response] || super.displayAsError; } @@ -237,52 +254,55 @@ @implementation FLEXWebsocketTransaction + (instancetype)withMessage:(NSURLSessionWebSocketMessage *)message task:(NSURLSessionWebSocketTask *)task direction:(FLEXWebsocketMessageDirection)direction - startTime:(NSDate *)started { + startTime:(NSDate *)started +{ FLEXWebsocketTransaction *wst = [self withRequest:task.originalRequest startTime:started]; wst->_message = message; wst->_direction = direction; - + // Populate receivedDataLength if (direction == FLEXWebsocketIncoming) { wst.receivedDataLength = wst.dataLength; wst.state = FLEXNetworkTransactionStateFinished; } - + // Populate thumbnail image if (message.type == NSURLSessionWebSocketMessageTypeData) { wst.thumbnail = FLEXResources.binaryIcon; } else { wst.thumbnail = FLEXResources.textIcon; } - + return wst; } + (instancetype)withMessage:(NSURLSessionWebSocketMessage *)message task:(NSURLSessionWebSocketTask *)task - direction:(FLEXWebsocketMessageDirection)direction { + direction:(FLEXWebsocketMessageDirection)direction +{ return [self withMessage:message task:task direction:direction startTime:NSDate.date]; } -- (NSArray *)details API_AVAILABLE(ios(13.0)) { +- (NSArray *)details API_AVAILABLE(ios(13.0)) +{ return @[ self.direction == FLEXWebsocketOutgoing ? @"SENT →" : @"→ RECEIVED", [NSByteCountFormatter stringFromByteCount:self.dataLength - countStyle:NSByteCountFormatterCountStyleBinary - ] + countStyle:NSByteCountFormatterCountStyleBinary] ]; } -- (int64_t)dataLength { +- (int64_t)dataLength +{ if (self.message) { if (self.message.type == NSURLSessionWebSocketMessageTypeString) { return self.message.string.length; } - + return self.message.data.length; } - + return 0; } From 2d3d789f228bacdbbec5dba4cd3bbb46ab470119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?enes=20o=CC=88ztu=CC=88rk?= Date: Mon, 5 Jan 2026 00:04:00 +0300 Subject: [PATCH 3/3] docs: Add Enhanced Network Export features and screenshots to README --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47464b6b3a..5af860b305 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,28 @@ Once a view is selected, you can tap on the info bar below the toolbar to presen Modify Views -### Network History -When enabled, network debugging allows you to view all requests made using NSURLConnection or NSURLSession. Settings allow you to adjust what kind of response bodies get cached and the maximum size limit of the response cache. You can choose to have network debugging enabled automatically on app launch. This setting is persisted across launches. +### Network History & Export +When enabled, network debugging allows you to view all requests made using NSURLConnection or NSURLSession. New in this version is the ability to export requests in multiple formats. Network History +#### Enhanced Network Export +Export captured requests with smart filters (Images, Analytics, Firebase): + + + + + + + + + + +
Export MenuExport Filters
Export MenuExport Filters
+ +* **Formats**: HAR, Postman Collection, Swagger/OpenAPI, Curl ZIP, Raw Text +* **Filters**: Settings allow you to exclude Images, Analytics SDKs (80+ providers), and Firebase Analytics while keeping Remote Config. + ### All Objects on the Heap FLEX queries malloc for all the live allocated memory blocks and searches for ones that look like objects. You can see everything from here.