diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 773acdfae160..4a583c9e88fc 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fa20aa25a9175838313543f726c7d0e88ab8ab5d611ea8d159bc549c27b0cda7", + "originHash" : "00e356be49694f361ebf54e56d39729aa3a31081e292e453ae34f75f9a1838e7", "pins" : [ { "identity" : "alamofire", @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "df43b1d70fef11fb9035b1d9da7d4a80503c2a6f", - "version" : "0.10.1" + "revision" : "55b8b9a2373b7a6e734c5a6ffd1668da4bd7bdf6", + "version" : "0.11.0" } }, { @@ -232,6 +232,24 @@ "version" : "9.1.0" } }, + { + "identity" : "pulse", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Pulse", + "state" : { + "revision" : "6125ce7fb51b114ba71b761d18cfd5557923bd4d", + "version" : "5.1.4" + } + }, + { + "identity" : "pulseloghandler", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/PulseLogHandler", + "state" : { + "revision" : "477e9ef76615f0b76b43bb502af432fe6750b704", + "version" : "5.1.0" + } + }, { "identity" : "reachability", "kind" : "remoteSourceControl", diff --git a/Modules/Package.swift b/Modules/Package.swift index 05537222fc35..783c8af26d02 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -54,7 +54,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/wpxmlrpc", from: "0.9.0"), .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), - .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.10.1"), + .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.11.0"), // We can't use wordpress-rs branches nor commits here. Only tags work. .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20251101"), .package( @@ -62,6 +62,8 @@ let package = Package( revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de" ), .package(url: "https://github.com/wordpress-mobile/AztecEditor-iOS", from: "1.20.0"), + .package(url: "https://github.com/kean/Pulse", from: "5.0.0"), + .package(url: "https://github.com/kean/PulseLogHandler", from: "5.0.0"), ], targets: XcodeSupport.targets + [ .target(name: "AsyncImageKit", dependencies: [ @@ -371,6 +373,9 @@ enum XcodeSupport { .product(name: "MediaEditor", package: "MediaEditor-iOS"), .product(name: "NSObject-SafeExpectations", package: "NSObject-SafeExpectations"), .product(name: "NSURL-IDN", package: "NSURL-IDN"), + .product(name: "Pulse", package: "Pulse"), + .product(name: "PulseUI", package: "Pulse"), + .product(name: "PulseLogHandler", package: "PulseLogHandler"), .product(name: "Reachability", package: "Reachability"), .product(name: "Starscream", package: "Starscream"), .product(name: "SVProgressHUD", package: "SVProgressHUD"), diff --git a/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesList.swift b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesList.swift index a37733385d74..3adb87ea98fb 100644 --- a/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesList.swift +++ b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesList.swift @@ -5,26 +5,60 @@ public struct ExperimentalFeaturesList: View { @ObservedObject var viewModel: ExperimentalFeaturesViewModel + @AppStorage("isDeveloperModeEnabled") + private var isDeveloperModeEnabled = false + + @State private var tapCount = 0 + package init(viewModel: ExperimentalFeaturesViewModel) { self.viewModel = viewModel } + private var regularFeatures: [Feature] { + viewModel.items.filter { !$0.isSuperExperimental } + } + + private var developerFeatures: [Feature] { + viewModel.items.filter { $0.isSuperExperimental } + } + public var body: some View { List { Section { - ForEach(viewModel.items) { item in + ForEach(regularFeatures) { item in Toggle(item.name, isOn: viewModel.binding(for: item)) } } footer: { - if !viewModel.notes.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.notes, id: \.self) { note in - Text(note) - .font(.footnote) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 12) { + if !viewModel.notes.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.notes, id: \.self) { note in + Text(note) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + + if !isDeveloperModeEnabled { + HStack { + Spacer() + boltButton + Spacer() } } } + .padding(.top, 8) + } + + if isDeveloperModeEnabled && !developerFeatures.isEmpty { + Section { + ForEach(developerFeatures) { item in + Toggle(item.name, isOn: viewModel.binding(for: item)) + } + } header: { + Text(Strings.developerToolsSectionTitle) + } } } .listStyle(.insetGrouped) @@ -34,6 +68,39 @@ public struct ExperimentalFeaturesList: View { } } + private var boltButton: some View { + Image(systemName: "bolt.fill") + .font(.system(size: 20)) + .foregroundColor(.secondary) + .symbolEffect(.bounce.up, value: tapCount) + .onTapGesture { + handleBoltTap() + } + } + + private func handleBoltTap() { + tapCount += 1 + + let generator = UIImpactFeedbackGenerator(style: impactStyle(for: tapCount)) + generator.impactOccurred() + + if tapCount >= 5 { + withAnimation { + isDeveloperModeEnabled = true + } + } + } + + private func impactStyle(for count: Int) -> UIImpactFeedbackGenerator.FeedbackStyle { + switch count { + case 1: return .light + case 2: return .medium + case 3: return .heavy + case 4: return .rigid + default: return .rigid + } + } + public static func asViewController( viewModel: ExperimentalFeaturesViewModel ) -> UIHostingController { @@ -50,6 +117,12 @@ public struct ExperimentalFeaturesList: View { value: "Experimental Features", comment: "The title for the experimental features list" ) + + static let developerToolsSectionTitle = NSLocalizedString( + "experimentalFeaturesList.developTools.section.title", + value: "Developer Tools", + comment: "Section title for developer tools" + ) } } diff --git a/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/Feature.swift b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/Feature.swift index c7d2ca856f9f..8e385076512d 100644 --- a/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/Feature.swift +++ b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/Feature.swift @@ -3,12 +3,14 @@ import Foundation public struct Feature: Identifiable { public let name: String public let key: String + public let isSuperExperimental: Bool public var id: String { key } - public init(name: String, key: String) { + public init(name: String, key: String, isSuperExperimental: Bool = false) { self.name = name self.key = key + self.isSuperExperimental = isSuperExperimental } package static let SampleData: [Feature] = [ diff --git a/WordPress/Classes/Networking/PulseMiddleware.swift b/WordPress/Classes/Networking/PulseMiddleware.swift new file mode 100644 index 000000000000..90a187181f1a --- /dev/null +++ b/WordPress/Classes/Networking/PulseMiddleware.swift @@ -0,0 +1,59 @@ +import Pulse +import WordPressAPI +import WordPressAPIInternal + +final class PulseMiddleware: Middleware { + + private static let errorStatusCodes: [UInt16] = [ + 400, 401, 402, 403, 404, 419, 429, + 500 + ] + + func process( + requestExecutor: any RequestExecutor, + response: WpNetworkResponse, + request: WpNetworkRequest, + context: RequestContext? + ) async throws -> WpNetworkResponse { + + LoggerStore.shared.storeRequest( + convertToUrlRequest(request), + response: try convertToUrlResponse(response), + error: Self.errorStatusCodes.contains(response.statusCode) ? parseBodyAsError(response.body) : nil, + data: response.body + ) + return response + } + + private func convertToUrlRequest(_ original: WpNetworkRequest) -> URLRequest { + let url = URL(string: original.url())! + var request = URLRequest(url: url) + request.httpMethod = "\(original.method())" + request.allHTTPHeaderFields = original.headerMap().toFlatMap() + request.httpBody = original.body()?.contents() + return request + } + + private func convertToUrlResponse(_ original: WpNetworkResponse) throws -> URLResponse? { + HTTPURLResponse( + url: try original.requestUrl.asURL(), + statusCode: Int(original.statusCode), + httpVersion: nil, + headerFields: original.responseHeaderMap.toFlatMap() + ) + } + + // TODO: This implementation should probably use the underlying Rust implementation + private func parseBodyAsError(_ data: Data) -> Error? { + try? JSONDecoder().decode(WpError.self, from: data) + } + + struct WpError: Codable, Error { + let code: Int + let message: String + + var description: String { + message + } + } +} diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 6fa599adcc8b..18625576bcaa 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -40,7 +40,10 @@ extension WordPressClient { urlSession: session, apiUrlResolver: resolver, authenticationProvider: provider, - appNotifier: notifier + middlewarePipeline: MiddlewarePipeline(middlewares: [ + PulseMiddleware() + ]), + appNotifier: notifier, ) self.init(api: api, rootUrl: apiRootURL) } diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 64e938f2129d..f09ef711d3a2 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -5,6 +5,9 @@ import AutomatticTracks import BuildSettingsKit import CocoaLumberjackSwift import DesignSystem +import Logging +import Pulse +import PulseLogHandler import Reachability import SFHFKeychainUtils import SVProgressHUD @@ -80,6 +83,9 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { DesignSystem.FontManager.registerCustomFonts() AssertionLoggerDependencyContainer.logger = AssertionLogger() UITestConfigurator.prepareApplicationForUITests(in: application, window: window) + if FeatureFlag.pulse.enabled { + LoggingSystem.bootstrap(PersistentLogHandler.init) + } AppAppearance.overrideAppearance() MemoryCache.shared.register() @@ -115,6 +121,8 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { DDLogInfo("didFinishLaunchingWithOptions state: \(application.applicationState)") + Logger(label: "App") + .info("didFinishLaunchingWithOptions state: \(application.applicationState)") ABTest.start() diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 9f344b213e5f..a0b504322b5a 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -27,6 +27,7 @@ public enum FeatureFlag: Int, CaseIterable { case intelligence case newSupport case nativeBlockInserter + case pulse /// Returns a boolean indicating if the feature is enabled. /// @@ -86,6 +87,8 @@ public enum FeatureFlag: Int, CaseIterable { return false case .nativeBlockInserter: return true + case .pulse: + return BuildConfiguration.current == .debug } } @@ -130,6 +133,7 @@ extension FeatureFlag { case .intelligence: "Intelligence" case .newSupport: "New Support" case .nativeBlockInserter: "Native Block Inserter" + case .pulse: "Extensive Logging" } } } diff --git a/WordPress/Classes/Utility/Editor/EditorConfiguration+Blog.swift b/WordPress/Classes/Utility/Editor/EditorConfiguration+Blog.swift index e226119311c7..81e3b8a52077 100644 --- a/WordPress/Classes/Utility/Editor/EditorConfiguration+Blog.swift +++ b/WordPress/Classes/Utility/Editor/EditorConfiguration+Blog.swift @@ -46,6 +46,7 @@ extension EditorConfiguration { // Limited to Jetpack-connected sites until editor assets endpoint is available in WordPress core .setShouldUsePlugins(Self.shouldEnablePlugins(for: blog, appPassword: applicationPassword)) .setLocale(WordPressComLanguageDatabase.shared.deviceLanguage.slug) + .setEnableNetworkLogging(true) if let blogUrl = blog.url { builder = builder.setSiteUrl(blogUrl) diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift index f37e02d0f65c..4c7ac4578d3b 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift @@ -37,6 +37,7 @@ final class CommentGutenbergEditorViewController: UIViewController { let configuration = EditorConfigurationBuilder(content: initialContent ?? "") .setShouldHideTitle(true) + .setEnableNetworkLogging(true) .build() let editorVC = GutenbergKit.EditorViewController(configuration: configuration) @@ -61,6 +62,7 @@ final class CommentGutenbergEditorViewController: UIViewController { } extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControllerDelegate { + func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { // Do nothing } @@ -108,4 +110,9 @@ extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControlle func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { // Do nothing } + + func editor(_ viewController: GutenbergKit.EditorViewController, didLogNetworkRequest request: GutenbergKit.RecordedNetworkRequest) { + + } + } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift index 65c553953360..0dbff9b4d51a 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift @@ -7,6 +7,7 @@ import WordPressData import WordPressShared import ShareExtensionCore import SVProgressHUD +import PulseUI import WordPressFlux import DesignSystem import WordPressUI @@ -550,6 +551,12 @@ private extension AppSettingsViewController { action: pushDebugMenu() ) + let loggerRow = NavigationItemRow(title: Strings.logger, icon: UIImage(systemName: "record.circle")) { [weak self] _ in + UserDefaults.standard.set(false, forKey: "pulse-disable-support-prompts") + let mainVC = PulseUI.MainViewController() + self?.present(mainVC, animated: true) + } + let designSystem = NavigationItemRow( title: NSLocalizedString("Design System", comment: "Navigates to design system gallery only available in development builds"), icon: UIImage(systemName: "paintpalette"), @@ -586,6 +593,10 @@ private extension AppSettingsViewController { rows.append(designSystem) } + if FeatureFlag.pulse.enabled { + rows.append(loggerRow) + } + if let presenter = RootViewCoordinator.shared.whatIsNewScenePresenter as? WhatIsNewScenePresenter, presenter.versionHasAnnouncements, FeatureFlag.whatsNew.enabled { @@ -636,5 +647,11 @@ extension AppSettingsViewController { value: "Experimental Features", comment: "The list item of experimental features that users can choose to enable" ) + + static let logger = NSLocalizedString( + "applicationSettings.logger", + value: "Logger", + comment: "A item in the menu" + ) } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift index c77d900f071a..9714cf6af43f 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift @@ -11,6 +11,7 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid FeatureFlag.allowApplicationPasswords, RemoteFeatureFlag.newGutenberg, FeatureFlag.newSupport, + FeatureFlag.pulse, ] private let flagStore = FeatureFlagOverrideStore() @@ -23,7 +24,8 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid flags.map { flag in WordPressUI.Feature( name: flag.description, - key: flag.key + key: flag.key, + isSuperExperimental: flag.key == FeatureFlag.pulse.key ) } } @@ -56,6 +58,19 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid self.presentViewController(UIHostingController(rootView: view)) return } + + if feature.key == FeatureFlag.pulse.key && newValue { + let alert = UIAlertController(title: Strings.pulseAlertTitle, message: Strings.pulseAlertMessage, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: SharedStrings.Button.cancel, style: .cancel, handler: { _ in + self.flagStore.override(self.flag(for: feature), withValue: false) + })) + alert.addAction(UIAlertAction(title: Strings.pulseAlertConfirm, style: .default, handler: { _ in + fatalError("Restarting") + })) + + self.presentViewController(alert) + return + } } private func flag(for feature: WordPressUI.Feature) -> OverridableFlag { @@ -81,5 +96,9 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid static let editorFeedbackDecline = NSLocalizedString("experimentalFeatures.editorFeedbackDecline", value: "Not now", comment: "Dismiss button title for the alert asking for feedback") static let editorFeedbackAccept = NSLocalizedString("experimentalFeatures.editorFeedbackAccept", value: "Send feedback", comment: "Accept button title for the alert asking for feedback") static let editorNote = NSLocalizedString("experimentalFeatures.editorNote", value: "Experimental Block Editor will become the default in a future release and the ability to disable it will be removed.", comment: "Communicates the future removal of the option to disable the experimental editor, displayed beneath the experimental features list") + + static let pulseAlertTitle = NSLocalizedString("experimentalFeatures.extensiveLogging.alert.title", value: "Enable Extensive Logging?", comment: "Alert title when enabling Pulse logging feature") + static let pulseAlertMessage = NSLocalizedString("experimentalFeatures.extensiveLogging.alert.message", value: "This will enable extensive local logging for debugging purposes and add a new Logger row in App Settings. This is not recommended unless you know what you're doing. The app will restart to apply changes.", comment: "Alert message explaining Pulse logging feature and warning users") + static let pulseAlertConfirm = NSLocalizedString("experimentalFeatures.extensiveLogging.alert.confirm", value: "Apply & Restart", comment: "Button to confirm enabling Pulse logging and restart the app") } } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 62d4625ab5b0..6533b7bac0f6 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -10,6 +10,7 @@ import WordPressShared import WebKit import CocoaLumberjackSwift import Photos +import Pulse class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor { @@ -527,6 +528,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate { + func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { if !editorSession.started { // Note that this method is also used to track startup performance @@ -681,6 +683,32 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate return WPMediaType(rawValue: mediaType) } + + func editor(_ viewController: GutenbergKit.EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest) { + + guard let url = URL(string: request.url) else { + return + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + urlRequest.allHTTPHeaderFields = request.requestHeaders + urlRequest.httpBody = request.requestBody?.data(using: .utf8) + + let httpResponse = HTTPURLResponse( + url: url, + statusCode: request.status, + httpVersion: nil, + headerFields: request.responseHeaders + ) + + LoggerStore.shared.storeRequest( + urlRequest, + response: httpResponse, + error: nil, + data: request.responseBody?.data(using: .utf8) + ) + } } // MARK: - GutenbergBridgeDelegate