diff --git a/src/cordova/apple/OutlineAppleLib/Package.swift b/src/cordova/apple/OutlineAppleLib/Package.swift index 4556f270fd..b716d4a2cb 100644 --- a/src/cordova/apple/OutlineAppleLib/Package.swift +++ b/src/cordova/apple/OutlineAppleLib/Package.swift @@ -8,13 +8,14 @@ let package = Package( products: [ .library( name: "OutlineAppleLib", - targets: ["Tun2socks", "OutlineTunnel"]), + targets: ["Tun2socks", "OutlineSentryLogger", "OutlineTunnel"]), .library( name: "PacketTunnelProvider", targets: ["PacketTunnelProvider"]), ], dependencies: [ .package(url: "https://github.com/CocoaLumberjack/CocoaLumberjack.git", from: "3.7.4"), + .package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"), ], targets: [ .target( @@ -29,6 +30,13 @@ let package = Package( .headerSearchPath("Internal"), ] ), + .target( + name: "OutlineSentryLogger", + dependencies: [ + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), + .product(name: "Sentry", package: "sentry-cocoa") + ] + ), .target( name: "OutlineTunnel", dependencies: [ diff --git a/src/cordova/apple/OutlineAppleLib/Sources/OutlineSentryLogger/OutlineSentryLogger.swift b/src/cordova/apple/OutlineAppleLib/Sources/OutlineSentryLogger/OutlineSentryLogger.swift new file mode 100644 index 0000000000..ffa64dc895 --- /dev/null +++ b/src/cordova/apple/OutlineAppleLib/Sources/OutlineSentryLogger/OutlineSentryLogger.swift @@ -0,0 +1,117 @@ +// Copyright 2018 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import CocoaLumberjack +import CocoaLumberjackSwift +import Sentry + +// Custom CocoaLumberjack logger that logs messages to Sentry. +public class OutlineSentryLogger: DDAbstractLogger { + private static let kDateFormat = "yyyy/MM/dd HH:mm:ss:SSS" + private static let kDatePattern = "[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}:[0-9]{3}" + + private var logsDirectory: String! + + // Initializes CocoaLumberjack, adding itself as a logger. + public init(forAppGroup appGroup: String) { + super.init() + guard let containerUrl = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroup) else { + DDLogError("Failed to retrieve app container directory") + return + } + self.logsDirectory = containerUrl.appendingPathComponent("Logs").path + + DDLog.add(self) + DDLog.add(DDOSLogger.sharedInstance) + dynamicLogLevel = DDLogLevel.info + } + + // Adds |logMessage| to Sentry as a breadcrumb. + public override func log(message logMessage: DDLogMessage) { + let breadcrumb = Breadcrumb(level: ddLogLevelToSentryLevel(logMessage.level), category:"App") + breadcrumb.message = logMessage.message + breadcrumb.timestamp = logMessage.timestamp + SentrySDK.addBreadcrumb(crumb: breadcrumb) + } + + private func ddLogLevelToSentryLevel(_ level: DDLogLevel) -> SentryLevel { + switch level { + case .error: + return .error + case .warning: + return .warning + case .info: + return .info + default: + return .debug + } + } + + // Reads VpnExtension logs and adds them to Sentry as breadcrumbs. + public func addVpnExtensionLogsToSentry(maxBreadcrumbsToAdd: Int) { + var logs: [String] + do { + logs = try FileManager.default.contentsOfDirectory(atPath: self.logsDirectory) + } catch { + DDLogError("Failed to list logs directory. Not sending VPN logs") + return + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = OutlineSentryLogger.kDateFormat + var numBreadcrumbsAdded: UInt = 0 + // Log files are named by date, get the most recent. + for logFile in logs.sorted().reversed() { + let logFilePath = (self.logsDirectory as NSString).appendingPathComponent(logFile) + DDLogDebug("Reading log file: \(String(describing: logFilePath))") + do { + let logContents = try String(contentsOf: NSURL.fileURL(withPath: logFilePath)) + // Order log lines descending by time. + let logLines = logContents.components(separatedBy: "\n").reversed() + for line in logLines { + if numBreadcrumbsAdded >= maxBreadcrumbsToAdd { + return + } + if let (timestamp, message) = parseTimestamp(in: line) { + let breadcrumb = Breadcrumb(level: .info, category: "VpnExtension") + breadcrumb.timestamp = dateFormatter.date(from: timestamp) + breadcrumb.message = message + SentrySDK.addBreadcrumb(crumb: breadcrumb) + numBreadcrumbsAdded += 1 + } + } + } catch let error { + DDLogError("Failed to read logs: \(error)") + } + } + } + + private func parseTimestamp(in log:String) -> (String, String)? { + do { + let regex = try NSRegularExpression(pattern: OutlineSentryLogger.kDatePattern) + let logNsString = log as NSString // Cast to access NSString length and substring methods. + let results = regex.matches(in: log, range: NSRange(location: 0, length: logNsString.length)) + if !results.isEmpty { + let timestamp = logNsString.substring(with: results[0].range) + let message = logNsString.substring(from: timestamp.count) + .trimmingCharacters(in: .whitespacesAndNewlines) + return (timestamp, message) + } + } catch let error { + DDLogError("Failed to parse timestamp: \(error)") + } + return nil + } +} diff --git a/src/cordova/apple/OutlineAppleLib/Sources/PacketTunnelProvider/PacketTunnelProvider.m b/src/cordova/apple/OutlineAppleLib/Sources/PacketTunnelProvider/PacketTunnelProvider.m index 46c42ff608..0c2304fff9 100644 --- a/src/cordova/apple/OutlineAppleLib/Sources/PacketTunnelProvider/PacketTunnelProvider.m +++ b/src/cordova/apple/OutlineAppleLib/Sources/PacketTunnelProvider/PacketTunnelProvider.m @@ -60,15 +60,7 @@ - (id)init { id logFileManager = [[DDLogFileManagerDefault alloc] initWithLogsDirectory:logsDirectory]; _fileLogger = [[DDFileLogger alloc] initWithLogFileManager:logFileManager]; -#if TARGET_OS_IPHONE [DDLog addLogger:[DDOSLogger sharedInstance]]; -#else - if (@available(macOS 10.12, *)) { - [DDLog addLogger:[DDOSLogger sharedInstance]]; - } else { - [DDLog addLogger:[DDASLLogger sharedInstance]]; - } -#endif [DDLog addLogger:_fileLogger]; _tunnelStore = [[OutlineTunnelStore alloc] initWithAppGroup:appGroup]; diff --git a/src/cordova/apple/xcode/ios/Outline.xcodeproj/project.pbxproj b/src/cordova/apple/xcode/ios/Outline.xcodeproj/project.pbxproj index 00b350b451..1a0c2cc6e8 100755 --- a/src/cordova/apple/xcode/ios/Outline.xcodeproj/project.pbxproj +++ b/src/cordova/apple/xcode/ios/Outline.xcodeproj/project.pbxproj @@ -24,7 +24,6 @@ 5F7F90AE0E924FD7B065C415 /* CDVStatusBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 0394302BA6114B2AB648D4FF /* CDVStatusBar.m */; }; 6AFF5BF91D6E424B00AB3073 /* CDVLaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6AFF5BF81D6E424B00AB3073 /* CDVLaunchScreen.storyboard */; }; F63DC2182970AFFA00D92E0A /* OutlineAppleLib in Frameworks */ = {isa = PBXBuildFile; productRef = F63DC2172970AFFA00D92E0A /* OutlineAppleLib */; }; - FC8C31091FAA8032004262BE /* OutlineSentryLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC8C31081FAA8032004262BE /* OutlineSentryLogger.swift */; }; FC8C310B1FAA814A004262BE /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC8C310A1FAA814A004262BE /* NetworkExtension.framework */; }; FC8C310C1FAA88FB004262BE /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC8C310A1FAA814A004262BE /* NetworkExtension.framework */; }; /* End PBXBuildFile section */ @@ -116,7 +115,6 @@ F63DC2162970AFE600D92E0A /* OutlineAppleLib */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = OutlineAppleLib; path = ../../src/cordova/apple/OutlineAppleLib; sourceTree = ""; }; F840E1F0165FE0F500CFE078 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = config.xml; path = Outline/config.xml; sourceTree = ""; }; FC55AB411F4F960A0056F12C /* VpnExtension-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "VpnExtension-Info.plist"; path = "Outline/VpnExtension-Info.plist"; sourceTree = SOURCE_ROOT; }; - FC8C31081FAA8032004262BE /* OutlineSentryLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OutlineSentryLogger.swift; path = "cordova-plugin-outline/OutlineSentryLogger.swift"; sourceTree = ""; }; FC8C310A1FAA814A004262BE /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -244,7 +242,6 @@ 307C750510C5A3420062BCA9 /* Plugins */ = { isa = PBXGroup; children = ( - FC8C31081FAA8032004262BE /* OutlineSentryLogger.swift */, 0394302BA6114B2AB648D4FF /* CDVStatusBar.m */, 0168F53D3AFF46F5B346C874 /* CDVStatusBar.h */, AAFAFA54943F490EAF4CD5BC /* OutlinePlugin.swift */, @@ -468,7 +465,6 @@ 302D95F114D2391D003F00A1 /* MainViewController.m in Sources */, 5F7F90AE0E924FD7B065C415 /* CDVStatusBar.m in Sources */, 2A617D29B96942E58B082FAC /* OutlinePlugin.swift in Sources */, - FC8C31091FAA8032004262BE /* OutlineSentryLogger.swift in Sources */, 1273B4E700B84E31B2528701 /* CDVClipboard.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/src/cordova/apple/xcode/macos/Outline.xcodeproj/project.pbxproj b/src/cordova/apple/xcode/macos/Outline.xcodeproj/project.pbxproj index 6eaf4272e6..985e1bee13 100644 --- a/src/cordova/apple/xcode/macos/Outline.xcodeproj/project.pbxproj +++ b/src/cordova/apple/xcode/macos/Outline.xcodeproj/project.pbxproj @@ -35,7 +35,6 @@ FC5FF9501F3E1FD40032A745 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC5FF9461F3E1E8B0032A745 /* NetworkExtension.framework */; }; FC6E7F8E204DC1BE003CB365 /* CDVMacOsUrlHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6E7F8D204DC1BE003CB365 /* CDVMacOsUrlHandler.swift */; }; FC7D56051F86969E00ABD5CA /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7D56041F86969E00ABD5CA /* EventMonitor.swift */; }; - FC8C30FC1FA7DDCB004262BE /* OutlineSentryLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC8C30FB1FA7DDCB004262BE /* OutlineSentryLogger.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -132,7 +131,6 @@ FC5FF9461F3E1E8B0032A745 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; FC6E7F8D204DC1BE003CB365 /* CDVMacOsUrlHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CDVMacOsUrlHandler.swift; sourceTree = ""; }; FC7D56041F86969E00ABD5CA /* EventMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = ""; }; - FC8C30FB1FA7DDCB004262BE /* OutlineSentryLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OutlineSentryLogger.swift; path = "cordova-plugin-outline/OutlineSentryLogger.swift"; sourceTree = ""; }; FCB2DED41F3E3CAD000C6A44 /* VpnExtension.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = VpnExtension.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -178,7 +176,6 @@ isa = PBXGroup; children = ( AA09EA80E0C54DFFB24A1810 /* OutlinePlugin.swift */, - FC8C30FB1FA7DDCB004262BE /* OutlineSentryLogger.swift */, 469466FF1BDB44C081741BF5 /* CDVClipboard.m */, 19209C3AABCC45AB8CFDD974 /* CDVClipboard.h */, ); @@ -530,7 +527,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FC8C30FC1FA7DDCB004262BE /* OutlineSentryLogger.swift in Sources */, 70BD683318FFB02D00A1EFCF /* main.m in Sources */, 70BD686D18FFB0BF00A1EFCF /* AppDelegate.m in Sources */, FC6E7F8E204DC1BE003CB365 /* CDVMacOsUrlHandler.swift in Sources */, diff --git a/src/cordova/plugin/apple/src/OutlinePlugin.swift b/src/cordova/plugin/apple/src/OutlinePlugin.swift index 7a6cc21f60..9248154be7 100644 --- a/src/cordova/plugin/apple/src/OutlinePlugin.swift +++ b/src/cordova/plugin/apple/src/OutlinePlugin.swift @@ -16,329 +16,333 @@ import CocoaLumberjack import CocoaLumberjackSwift import NetworkExtension import Sentry + +import OutlineSentryLogger import OutlineTunnel @objcMembers class OutlinePlugin: CDVPlugin { - - private enum Action { - static let start = "start" - static let stop = "stop" - static let onStatusChange = "onStatusChange" - } - - public static let kAppQuitNotification = "outlinePluginAppQuitNotification" - public static let kVpnConnectedNotification = "outlineVpnConnected" - public static let kVpnDisconnectedNotification = "outlineVpnDisconnected" - public static let kMaxBreadcrumbs: UInt = 100 - - private var callbacks: [String: String]! - + + private enum Action { + static let start = "start" + static let stop = "stop" + static let onStatusChange = "onStatusChange" + } + + public static let kAppQuitNotification = "outlinePluginAppQuitNotification" + public static let kVpnConnectedNotification = "outlineVpnConnected" + public static let kVpnDisconnectedNotification = "outlineVpnDisconnected" + public static let kMaxBreadcrumbs: Int = 100 + + private var sentryLogger: OutlineSentryLogger! + private var callbacks: [String: String]! + #if os(macOS) - // cordova-osx does not support URL interception. Until it does, we have version-controlled - // AppDelegate.m (intercept) and Outline-Info.plist (register protocol) to handle ss:// URLs. - private var urlHandler: CDVMacOsUrlHandler? - private static let kPlatform = "macOS" + // cordova-osx does not support URL interception. Until it does, we have version-controlled + // AppDelegate.m (intercept) and Outline-Info.plist (register protocol) to handle ss:// URLs. + private var urlHandler: CDVMacOsUrlHandler? + private static let kPlatform = "macOS" + private static let kAppGroup = "QT8Z3Q9V3A.org.outline.macos.client" #else - private static let kPlatform = "iOS" + private static let kPlatform = "iOS" + private static let kAppGroup = "group.org.outline.ios.client" #endif - override func pluginInitialize() { - OutlineSentryLogger.sharedInstance.initializeLogging() - - callbacks = [String: String]() - - OutlineVpn.shared.onVpnStatusChange(onVpnStatusChange) - - #if os(macOS) - self.urlHandler = CDVMacOsUrlHandler.init(self.webView) - NotificationCenter.default.addObserver( - self, selector: #selector(self.stopVpnOnAppQuit), - name: NSNotification.Name(rawValue: OutlinePlugin.kAppQuitNotification), - object: nil) - #endif - - #if os(iOS) - self.migrateLocalStorage() - #endif - } - - /** - Starts the VPN. This method is idempotent for a given tunnel. - - Parameters: - - command: CDVInvokedUrlCommand, where command.arguments - - tunnelId: string, ID of the tunnel - - config: [String: Any], represents a server configuration - */ - func start(_ command: CDVInvokedUrlCommand) { - guard let tunnelId = command.argument(at: 0) as? String else { - return sendError("Missing tunnel ID", callbackId: command.callbackId, - errorCode: OutlineVpn.ErrorCode.illegalServerConfiguration) - } - DDLogInfo("\(Action.start) \(tunnelId)") - // TODO(fortuna): Move the config validation to the config parsing code in Go. - guard let configJson = command.argument(at: 1) as? [String: Any], containsExpectedKeys(configJson) else { - return sendError("Invalid configuration", callbackId: command.callbackId, - errorCode: OutlineVpn.ErrorCode.illegalServerConfiguration) + override func pluginInitialize() { + self.sentryLogger = OutlineSentryLogger(forAppGroup: OutlinePlugin.kAppGroup) + callbacks = [String: String]() + + OutlineVpn.shared.onVpnStatusChange(onVpnStatusChange) + +#if os(macOS) + self.urlHandler = CDVMacOsUrlHandler.init(self.webView) + NotificationCenter.default.addObserver( + self, selector: #selector(self.stopVpnOnAppQuit), + name: NSNotification.Name(rawValue: OutlinePlugin.kAppQuitNotification), + object: nil) +#endif + +#if os(iOS) + self.migrateLocalStorage() +#endif } - OutlineVpn.shared.start(tunnelId, configJson:configJson) { errorCode in - if errorCode == OutlineVpn.ErrorCode.noError { - #if os(macOS) - NotificationCenter.default.post( - name: NSNotification.Name(rawValue: OutlinePlugin.kVpnConnectedNotification), object: nil) - #endif - self.sendSuccess(callbackId: command.callbackId) - } else { - self.sendError("Failed to start VPN", callbackId: command.callbackId, - errorCode: errorCode) - } + + /** + Starts the VPN. This method is idempotent for a given tunnel. + - Parameters: + - command: CDVInvokedUrlCommand, where command.arguments + - tunnelId: string, ID of the tunnel + - config: [String: Any], represents a server configuration + */ + func start(_ command: CDVInvokedUrlCommand) { + guard let tunnelId = command.argument(at: 0) as? String else { + return sendError("Missing tunnel ID", callbackId: command.callbackId, + errorCode: OutlineVpn.ErrorCode.illegalServerConfiguration) + } + DDLogInfo("\(Action.start) \(tunnelId)") + // TODO(fortuna): Move the config validation to the config parsing code in Go. + guard let configJson = command.argument(at: 1) as? [String: Any], containsExpectedKeys(configJson) else { + return sendError("Invalid configuration", callbackId: command.callbackId, + errorCode: OutlineVpn.ErrorCode.illegalServerConfiguration) + } + OutlineVpn.shared.start(tunnelId, configJson:configJson) { errorCode in + if errorCode == OutlineVpn.ErrorCode.noError { +#if os(macOS) + NotificationCenter.default.post( + name: NSNotification.Name(rawValue: OutlinePlugin.kVpnConnectedNotification), object: nil) +#endif + self.sendSuccess(callbackId: command.callbackId) + } else { + self.sendError("Failed to start VPN", callbackId: command.callbackId, + errorCode: errorCode) + } + } } - } - - /** - Stops the VPN. Sends an error if the given tunnel is not running. - - Parameters: - - command: CDVInvokedUrlCommand, where command.arguments - - tunnelId: string, ID of the tunnel - */ - func stop(_ command: CDVInvokedUrlCommand) { - guard let tunnelId = command.argument(at: 0) as? String else { - return sendError("Missing tunnel ID", callbackId: command.callbackId) + + /** + Stops the VPN. Sends an error if the given tunnel is not running. + - Parameters: + - command: CDVInvokedUrlCommand, where command.arguments + - tunnelId: string, ID of the tunnel + */ + func stop(_ command: CDVInvokedUrlCommand) { + guard let tunnelId = command.argument(at: 0) as? String else { + return sendError("Missing tunnel ID", callbackId: command.callbackId) + } + DDLogInfo("\(Action.stop) \(tunnelId)") + OutlineVpn.shared.stop(tunnelId) + sendSuccess(callbackId: command.callbackId) +#if os(macOS) + NotificationCenter.default.post( + name: NSNotification.Name(rawValue: OutlinePlugin.kVpnDisconnectedNotification), object: nil) +#endif } - DDLogInfo("\(Action.stop) \(tunnelId)") - OutlineVpn.shared.stop(tunnelId) - sendSuccess(callbackId: command.callbackId) - #if os(macOS) - NotificationCenter.default.post( - name: NSNotification.Name(rawValue: OutlinePlugin.kVpnDisconnectedNotification), object: nil) - #endif - } - - func isRunning(_ command: CDVInvokedUrlCommand) { - guard let tunnelId = command.argument(at: 0) as? String else { - return sendError("Missing tunnel ID", callbackId: command.callbackId) + + func isRunning(_ command: CDVInvokedUrlCommand) { + guard let tunnelId = command.argument(at: 0) as? String else { + return sendError("Missing tunnel ID", callbackId: command.callbackId) + } + DDLogInfo("isRunning \(tunnelId)") + sendSuccess(OutlineVpn.shared.isActive(tunnelId), callbackId: command.callbackId) } - DDLogInfo("isRunning \(tunnelId)") - sendSuccess(OutlineVpn.shared.isActive(tunnelId), callbackId: command.callbackId) - } - - func onStatusChange(_ command: CDVInvokedUrlCommand) { - guard let tunnelId = command.argument(at: 0) as? String else { - return sendError("Missing tunnel ID", callbackId: command.callbackId) + + func onStatusChange(_ command: CDVInvokedUrlCommand) { + guard let tunnelId = command.argument(at: 0) as? String else { + return sendError("Missing tunnel ID", callbackId: command.callbackId) + } + DDLogInfo("\(Action.onStatusChange) \(tunnelId)") + setCallbackId(command.callbackId!, action: Action.onStatusChange, tunnelId: tunnelId) } - DDLogInfo("\(Action.onStatusChange) \(tunnelId)") - setCallbackId(command.callbackId!, action: Action.onStatusChange, tunnelId: tunnelId) - } - - // MARK: Error reporting - - func initializeErrorReporting(_ command: CDVInvokedUrlCommand) { - DDLogInfo("initializeErrorReporting") - guard let sentryDsn = command.argument(at: 0) as? String else { - return sendError("Missing error reporting API key.", callbackId: command.callbackId) + + // MARK: Error reporting + + func initializeErrorReporting(_ command: CDVInvokedUrlCommand) { + DDLogInfo("initializeErrorReporting") + guard let sentryDsn = command.argument(at: 0) as? String else { + return sendError("Missing error reporting API key.", callbackId: command.callbackId) + } + SentrySDK.start { options in + options.dsn = sentryDsn + options.maxBreadcrumbs = UInt(OutlinePlugin.kMaxBreadcrumbs) + // Remove device identifier, timezone, and memory stats. + options.beforeSend = { event in + event.context?["app"]?.removeValue(forKey: "device_app_hash") + if var device = event.context?["device"] { + device.removeValue(forKey: "timezone") + device.removeValue(forKey: "memory_size") + device.removeValue(forKey: "free_memory") + device.removeValue(forKey: "usable_memory") + device.removeValue(forKey: "storage_size") + event.context?["device"] = device + } + return event + } + } + sendSuccess(true, callbackId: command.callbackId) } - SentrySDK.start { options in - options.dsn = sentryDsn - options.maxBreadcrumbs = OutlinePlugin.kMaxBreadcrumbs - // Remove device identifier, timezone, and memory stats. - options.beforeSend = { event in - event.context?["app"]?.removeValue(forKey: "device_app_hash") - if var device = event.context?["device"] { - device.removeValue(forKey: "timezone") - device.removeValue(forKey: "memory_size") - device.removeValue(forKey: "free_memory") - device.removeValue(forKey: "usable_memory") - device.removeValue(forKey: "storage_size") - event.context?["device"] = device + + func reportEvents(_ command: CDVInvokedUrlCommand) { + var uuid: String + if let eventId = command.argument(at: 0) as? String { + // Associate this event with the one reported from JS. + SentrySDK.configureScope { scope in + scope.setTag(value: eventId, key: "user_event_id") + } + uuid = eventId + } else { + uuid = NSUUID().uuidString } - return event - } + self.sentryLogger.addVpnExtensionLogsToSentry(maxBreadcrumbsToAdd: OutlinePlugin.kMaxBreadcrumbs / 2) + SentrySDK.capture(message: "\(OutlinePlugin.kPlatform) report (\(uuid))") { scope in + scope.setLevel(.info) + } + self.sendSuccess(true, callbackId: command.callbackId) } - sendSuccess(true, callbackId: command.callbackId) - } - - func reportEvents(_ command: CDVInvokedUrlCommand) { - var uuid: String - if let eventId = command.argument(at: 0) as? String { - // Associate this event with the one reported from JS. - SentrySDK.configureScope { scope in - scope.setTag(value: eventId, key: "user_event_id") - } - uuid = eventId - } else { - uuid = NSUUID().uuidString + +#if os(macOS) + func quitApplication(_ command: CDVInvokedUrlCommand) { + NSApplication.shared.terminate(self) } - OutlineSentryLogger.sharedInstance.addVpnExtensionLogsToSentry() - SentrySDK.capture(message: "\(OutlinePlugin.kPlatform) report (\(uuid))") { scope in - scope.setLevel(.info) +#endif + + // MARK: Helpers + + @objc private func stopVpnOnAppQuit() { + if let activeTunnelId = OutlineVpn.shared.activeTunnelId { + OutlineVpn.shared.stop(activeTunnelId) + } } - self.sendSuccess(true, callbackId: command.callbackId) - } - + + // Receives NEVPNStatusDidChange notifications. Calls onTunnelStatusChange for the active + // tunnel. + func onVpnStatusChange(vpnStatus: NEVPNStatus, tunnelId: String) { + var tunnelStatus: Int + switch vpnStatus { + case .connected: #if os(macOS) - func quitApplication(_ command: CDVInvokedUrlCommand) { - NSApplication.shared.terminate(self) - } + NotificationCenter.default.post( + name: NSNotification.Name(rawValue: OutlinePlugin.kVpnConnectedNotification), object: nil) #endif - - // MARK: Helpers - - @objc private func stopVpnOnAppQuit() { - if let activeTunnelId = OutlineVpn.shared.activeTunnelId { - OutlineVpn.shared.stop(activeTunnelId) - } - } - - // Receives NEVPNStatusDidChange notifications. Calls onTunnelStatusChange for the active - // tunnel. - func onVpnStatusChange(vpnStatus: NEVPNStatus, tunnelId: String) { - var tunnelStatus: Int - switch vpnStatus { - case .connected: - #if os(macOS) - NotificationCenter.default.post( - name: NSNotification.Name(rawValue: OutlinePlugin.kVpnConnectedNotification), object: nil) - #endif - tunnelStatus = OutlineTunnel.TunnelStatus.connected.rawValue - case .disconnected: - #if os(macOS) - NotificationCenter.default.post( - name: NSNotification.Name(rawValue: OutlinePlugin.kVpnDisconnectedNotification), object: nil) - #endif - tunnelStatus = OutlineTunnel.TunnelStatus.disconnected.rawValue - case .reasserting: - tunnelStatus = OutlineTunnel.TunnelStatus.reconnecting.rawValue - default: - return; // Do not report transient or invalid states. - } - DDLogDebug("Calling onStatusChange (\(tunnelStatus)) for tunnel \(tunnelId)") - if let callbackId = getCallbackIdFor(action: Action.onStatusChange, - tunnelId: tunnelId, - keepCallback: true) { - let result = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: Int32(tunnelStatus)) - send(pluginResult: result, callbackId: callbackId, keepCallback: true) + tunnelStatus = OutlineTunnel.TunnelStatus.connected.rawValue + case .disconnected: +#if os(macOS) + NotificationCenter.default.post( + name: NSNotification.Name(rawValue: OutlinePlugin.kVpnDisconnectedNotification), object: nil) +#endif + tunnelStatus = OutlineTunnel.TunnelStatus.disconnected.rawValue + case .reasserting: + tunnelStatus = OutlineTunnel.TunnelStatus.reconnecting.rawValue + default: + return; // Do not report transient or invalid states. + } + DDLogDebug("Calling onStatusChange (\(tunnelStatus)) for tunnel \(tunnelId)") + if let callbackId = getCallbackIdFor(action: Action.onStatusChange, + tunnelId: tunnelId, + keepCallback: true) { + let result = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: Int32(tunnelStatus)) + send(pluginResult: result, callbackId: callbackId, keepCallback: true) + } } - } - - // Returns whether |config| contains all the expected keys - private func containsExpectedKeys(_ configJson: [String: Any]?) -> Bool { - return configJson?["host"] != nil && configJson?["port"] != nil && + + // Returns whether |config| contains all the expected keys + private func containsExpectedKeys(_ configJson: [String: Any]?) -> Bool { + return configJson?["host"] != nil && configJson?["port"] != nil && configJson?["password"] != nil && configJson?["method"] != nil - } - - // MARK: Callback helpers - - private func sendSuccess(callbackId: String, keepCallback: Bool = false) { - let result = CDVPluginResult(status: CDVCommandStatus_OK) - send(pluginResult: result, callbackId: callbackId, keepCallback: keepCallback) - } - - private func sendSuccess(_ operationResult: Bool, callbackId: String, keepCallback: Bool = false) { - let result = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: operationResult) - send(pluginResult: result, callbackId: callbackId, keepCallback: keepCallback) - } - - private func sendError(_ message: String, callbackId: String, - errorCode: OutlineVpn.ErrorCode = OutlineVpn.ErrorCode.undefined, - keepCallback: Bool = false) { - DDLogError(message) - let result = CDVPluginResult(status: CDVCommandStatus_ERROR, - messageAs: Int32(errorCode.rawValue)) - send(pluginResult: result, callbackId: callbackId, keepCallback: keepCallback) - } - - private func send(pluginResult: CDVPluginResult?, callbackId: String, keepCallback: Bool) { - guard let result = pluginResult else { - return DDLogWarn("Missing plugin result"); } - result.setKeepCallbackAs(keepCallback) - self.commandDelegate?.send(result, callbackId: callbackId) - } - - // Maps |action| and |tunnelId| to |callbackId| in the callbacks dictionary. - private func setCallbackId(_ callbackId: String, action: String, tunnelId: String) { - DDLogDebug("\(action):\(tunnelId):\(callbackId)") - callbacks["\(action):\(tunnelId)"] = callbackId - } - - // Retrieves the callback ID for |action| and |tunnelId|. Unmaps the entry if |keepCallback| - // is false. - private func getCallbackIdFor(action: String, tunnelId: String?, - keepCallback: Bool = false) -> String? { - guard let tunnelId = tunnelId else { - return nil + + // MARK: Callback helpers + + private func sendSuccess(callbackId: String, keepCallback: Bool = false) { + let result = CDVPluginResult(status: CDVCommandStatus_OK) + send(pluginResult: result, callbackId: callbackId, keepCallback: keepCallback) } - let key = "\(action):\(tunnelId)" - guard let callbackId = callbacks[key] else { - DDLogWarn("Callback id not found for action \(action) and tunnel \(tunnelId)") - return nil + + private func sendSuccess(_ operationResult: Bool, callbackId: String, keepCallback: Bool = false) { + let result = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: operationResult) + send(pluginResult: result, callbackId: callbackId, keepCallback: keepCallback) } - if (!keepCallback) { - callbacks.removeValue(forKey: key) + + private func sendError(_ message: String, callbackId: String, + errorCode: OutlineVpn.ErrorCode = OutlineVpn.ErrorCode.undefined, + keepCallback: Bool = false) { + DDLogError(message) + let result = CDVPluginResult(status: CDVCommandStatus_ERROR, + messageAs: Int32(errorCode.rawValue)) + send(pluginResult: result, callbackId: callbackId, keepCallback: keepCallback) } - return callbackId - } - - // Migrates local storage files from UIWebView to WKWebView. - private func migrateLocalStorage() { - // Local storage backing files have the following naming format: $scheme_$hostname_$port.localstorage - // With UIWebView, the app used the file:// scheme with no hostname and any port. - let kUIWebViewLocalStorageFilename = "file__0.localstorage" - // With WKWebView, the app uses the app:// scheme with localhost as a hostname and any port. - let kWKWebViewLocalStorageFilename = "app_localhost_0.localstorage" - - let fileManager = FileManager.default - let appLibraryDir = fileManager.urls(for: .libraryDirectory, in: .userDomainMask)[0] - - var uiWebViewLocalStorageDir: URL - if fileManager.fileExists(atPath: appLibraryDir.appendingPathComponent( - "WebKit/LocalStorage/\(kUIWebViewLocalStorageFilename)").relativePath) { - uiWebViewLocalStorageDir = appLibraryDir.appendingPathComponent("WebKit/LocalStorage") - } else { - uiWebViewLocalStorageDir = appLibraryDir.appendingPathComponent("Caches") - } - let uiWebViewLocalStorage = uiWebViewLocalStorageDir.appendingPathComponent(kUIWebViewLocalStorageFilename) - if !fileManager.fileExists(atPath: uiWebViewLocalStorage.relativePath) { - return DDLogInfo("Not migrating, UIWebView local storage files missing.") - } - - let wkWebViewLocalStorageDir = appLibraryDir.appendingPathComponent("WebKit/WebsiteData/LocalStorage/") - let wkWebViewLocalStorage = wkWebViewLocalStorageDir.appendingPathComponent(kWKWebViewLocalStorageFilename) - // Only copy the local storage files if they don't exist for WKWebView. - if fileManager.fileExists(atPath: wkWebViewLocalStorage.relativePath) { - return DDLogInfo("Not migrating, WKWebView local storage files present.") - } - DDLogInfo("Migrating UIWebView local storage to WKWebView") - - // Create the WKWebView local storage directory; this is safe if the directory already exists. - do { - try fileManager.createDirectory(at: wkWebViewLocalStorageDir, withIntermediateDirectories: true) - } catch { - return DDLogError("Failed to create WKWebView local storage directory") + + private func send(pluginResult: CDVPluginResult?, callbackId: String, keepCallback: Bool) { + guard let result = pluginResult else { + return DDLogWarn("Missing plugin result"); + } + result.setKeepCallbackAs(keepCallback) + self.commandDelegate?.send(result, callbackId: callbackId) } - - // Create a tmp directory and copy onto it the local storage files. - guard let tmpDir = try? fileManager.url(for: .itemReplacementDirectory, in: .userDomainMask, - appropriateFor: wkWebViewLocalStorage, create: true) else { - return DDLogError("Failed to create tmp dir") + + // Maps |action| and |tunnelId| to |callbackId| in the callbacks dictionary. + private func setCallbackId(_ callbackId: String, action: String, tunnelId: String) { + DDLogDebug("\(action):\(tunnelId):\(callbackId)") + callbacks["\(action):\(tunnelId)"] = callbackId } - do { - try fileManager.copyItem(at: uiWebViewLocalStorage, - to: tmpDir.appendingPathComponent(wkWebViewLocalStorage.lastPathComponent)) - try fileManager.copyItem(at: URL.init(fileURLWithPath: "\(uiWebViewLocalStorage.relativePath)-shm"), - to: tmpDir.appendingPathComponent("\(kWKWebViewLocalStorageFilename)-shm")) - try fileManager.copyItem(at: URL.init(fileURLWithPath: "\(uiWebViewLocalStorage.relativePath)-wal"), - to: tmpDir.appendingPathComponent("\(kWKWebViewLocalStorageFilename)-wal")) - } catch { - return DDLogError("Local storage migration failed.") + + // Retrieves the callback ID for |action| and |tunnelId|. Unmaps the entry if |keepCallback| + // is false. + private func getCallbackIdFor(action: String, tunnelId: String?, + keepCallback: Bool = false) -> String? { + guard let tunnelId = tunnelId else { + return nil + } + let key = "\(action):\(tunnelId)" + guard let callbackId = callbacks[key] else { + DDLogWarn("Callback id not found for action \(action) and tunnel \(tunnelId)") + return nil + } + if (!keepCallback) { + callbacks.removeValue(forKey: key) + } + return callbackId } - - // Atomically move the tmp directory to the WKWebView local storage directory. - guard let _ = try? fileManager.replaceItemAt(wkWebViewLocalStorageDir, withItemAt: tmpDir, - backupItemName: nil, options: .usingNewMetadataOnly) else { - return DDLogError("Failed to copy tmp dir to WKWebView local storage dir") + + // Migrates local storage files from UIWebView to WKWebView. + private func migrateLocalStorage() { + // Local storage backing files have the following naming format: $scheme_$hostname_$port.localstorage + // With UIWebView, the app used the file:// scheme with no hostname and any port. + let kUIWebViewLocalStorageFilename = "file__0.localstorage" + // With WKWebView, the app uses the app:// scheme with localhost as a hostname and any port. + let kWKWebViewLocalStorageFilename = "app_localhost_0.localstorage" + + let fileManager = FileManager.default + let appLibraryDir = fileManager.urls(for: .libraryDirectory, in: .userDomainMask)[0] + + var uiWebViewLocalStorageDir: URL + if fileManager.fileExists(atPath: appLibraryDir.appendingPathComponent( + "WebKit/LocalStorage/\(kUIWebViewLocalStorageFilename)").relativePath) { + uiWebViewLocalStorageDir = appLibraryDir.appendingPathComponent("WebKit/LocalStorage") + } else { + uiWebViewLocalStorageDir = appLibraryDir.appendingPathComponent("Caches") + } + let uiWebViewLocalStorage = uiWebViewLocalStorageDir.appendingPathComponent(kUIWebViewLocalStorageFilename) + if !fileManager.fileExists(atPath: uiWebViewLocalStorage.relativePath) { + return DDLogInfo("Not migrating, UIWebView local storage files missing.") + } + + let wkWebViewLocalStorageDir = appLibraryDir.appendingPathComponent("WebKit/WebsiteData/LocalStorage/") + let wkWebViewLocalStorage = wkWebViewLocalStorageDir.appendingPathComponent(kWKWebViewLocalStorageFilename) + // Only copy the local storage files if they don't exist for WKWebView. + if fileManager.fileExists(atPath: wkWebViewLocalStorage.relativePath) { + return DDLogInfo("Not migrating, WKWebView local storage files present.") + } + DDLogInfo("Migrating UIWebView local storage to WKWebView") + + // Create the WKWebView local storage directory; this is safe if the directory already exists. + do { + try fileManager.createDirectory(at: wkWebViewLocalStorageDir, withIntermediateDirectories: true) + } catch { + return DDLogError("Failed to create WKWebView local storage directory") + } + + // Create a tmp directory and copy onto it the local storage files. + guard let tmpDir = try? fileManager.url(for: .itemReplacementDirectory, in: .userDomainMask, + appropriateFor: wkWebViewLocalStorage, create: true) else { + return DDLogError("Failed to create tmp dir") + } + do { + try fileManager.copyItem(at: uiWebViewLocalStorage, + to: tmpDir.appendingPathComponent(wkWebViewLocalStorage.lastPathComponent)) + try fileManager.copyItem(at: URL.init(fileURLWithPath: "\(uiWebViewLocalStorage.relativePath)-shm"), + to: tmpDir.appendingPathComponent("\(kWKWebViewLocalStorageFilename)-shm")) + try fileManager.copyItem(at: URL.init(fileURLWithPath: "\(uiWebViewLocalStorage.relativePath)-wal"), + to: tmpDir.appendingPathComponent("\(kWKWebViewLocalStorageFilename)-wal")) + } catch { + return DDLogError("Local storage migration failed.") + } + + // Atomically move the tmp directory to the WKWebView local storage directory. + guard let _ = try? fileManager.replaceItemAt(wkWebViewLocalStorageDir, withItemAt: tmpDir, + backupItemName: nil, options: .usingNewMetadataOnly) else { + return DDLogError("Failed to copy tmp dir to WKWebView local storage dir") + } + + DDLogInfo("Local storage migration succeeded") } - - DDLogInfo("Local storage migration succeeded") - } } diff --git a/src/cordova/plugin/apple/src/OutlineSentryLogger.swift b/src/cordova/plugin/apple/src/OutlineSentryLogger.swift deleted file mode 100644 index 119e97658b..0000000000 --- a/src/cordova/plugin/apple/src/OutlineSentryLogger.swift +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright 2018 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import CocoaLumberjack -import CocoaLumberjackSwift -import Sentry - -// Custom CocoaLumberjack logger that logs messages to Sentry. -@objc -class OutlineSentryLogger: DDAbstractLogger { - - static let sharedInstance = OutlineSentryLogger() - -#if os(macOS) - private static let kAppGroup = "QT8Z3Q9V3A.org.outline.macos.client" -#else - private static let kAppGroup = "group.org.outline.ios.client" -#endif - private static let kDateFormat = "yyyy/MM/dd HH:mm:ss:SSS" - private static let kDatePattern = "[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}:[0-9]{3}" - - private var logsDirectory: String! - - // Initializes CocoaLumberjack, adding itself as a logger. - func initializeLogging() { - guard let containerUrl = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: OutlineSentryLogger.kAppGroup) else { - DDLogError("Failed to retrieve app container directory") - return - } - self.logsDirectory = containerUrl.appendingPathComponent("Logs").path - - DDLog.add(OutlineSentryLogger.sharedInstance) - #if os(iOS) - DDLog.add(DDOSLogger.sharedInstance) - #else - if #available(OSX 10.12, *) { - DDLog.add(DDOSLogger.sharedInstance) - } else { - // Fallback on earlier versions - DDLog.add(DDASLLogger.sharedInstance) - } - #endif - dynamicLogLevel = DDLogLevel.info - } - - // Adds |logMessage| to Sentry as a breadcrumb. - override func log(message logMessage: DDLogMessage) { - let breadcrumb = Breadcrumb(level: ddLogLevelToSentryLevel(logMessage.level), category:"App") - breadcrumb.message = logMessage.message - breadcrumb.timestamp = logMessage.timestamp - SentrySDK.addBreadcrumb(crumb: breadcrumb) - } - - private func ddLogLevelToSentryLevel(_ level: DDLogLevel) -> SentryLevel { - switch level { - case .error: - return .error - case .warning: - return .warning - case .info: - return .info - default: - return .debug - } - } - - // Reads VpnExtension logs and adds them to Sentry as breadcrumbs. - func addVpnExtensionLogsToSentry() { - var logs: [String] - do { - logs = try FileManager.default.contentsOfDirectory(atPath: self.logsDirectory) - } catch { - DDLogError("Failed to list logs directory. Not sending VPN logs") - return - } - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = OutlineSentryLogger.kDateFormat - var numBreadcrumbsAdded: UInt = 0 - // Log files are named by date, get the most recent. - for logFile in logs.sorted().reversed() { - let logFilePath = (self.logsDirectory as NSString).appendingPathComponent(logFile) - DDLogDebug("Reading log file: \(String(describing: logFilePath))") - do { - let logContents = try String(contentsOf: NSURL.fileURL(withPath: logFilePath)) - // Order log lines descending by time. - let logLines = logContents.components(separatedBy: "\n").reversed() - for line in logLines { - if numBreadcrumbsAdded >= OutlinePlugin.kMaxBreadcrumbs / 2 { - return - } - if let (timestamp, message) = parseTimestamp(in: line) { - let breadcrumb = Breadcrumb(level: .info, category: "VpnExtension") - breadcrumb.timestamp = dateFormatter.date(from: timestamp) - breadcrumb.message = message - SentrySDK.addBreadcrumb(crumb: breadcrumb) - numBreadcrumbsAdded += 1 - } - } - } catch let error { - DDLogError("Failed to read logs: \(error)") - } - } - } - - private func parseTimestamp(in log:String) -> (String, String)? { - do { - let regex = try NSRegularExpression(pattern: OutlineSentryLogger.kDatePattern) - let logNsString = log as NSString // Cast to access NSString length and substring methods. - let results = regex.matches(in: log, range: NSRange(location: 0, length: logNsString.length)) - if !results.isEmpty { - let timestamp = logNsString.substring(with: results[0].range) - let message = logNsString.substring(from: timestamp.count) - .trimmingCharacters(in: .whitespacesAndNewlines) - return (timestamp, message) - } - } catch let error { - DDLogError("Failed to parse timestamp: \(error)") - } - return nil - } -} diff --git a/src/cordova/plugin/plugin.xml b/src/cordova/plugin/plugin.xml index 02c0f23d32..a7406fff57 100644 --- a/src/cordova/plugin/plugin.xml +++ b/src/cordova/plugin/plugin.xml @@ -80,7 +80,6 @@ - @@ -93,7 +92,6 @@ -