From 970af02afd67ac8cbd37c21196569a7af610a081 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Tue, 15 Oct 2024 15:38:36 +0200 Subject: [PATCH 1/3] Adds TipKitUtils --- Package.swift | 11 +- Sources/TipKitUtils/TipGrouping.swift | 150 +++++++++++++++++++++ Sources/TipKitUtils/TipKitController.swift | 94 +++++++++++++ 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 Sources/TipKitUtils/TipGrouping.swift create mode 100644 Sources/TipKitUtils/TipKitController.swift diff --git a/Package.swift b/Package.swift index d5241f75e..c740b23c7 100644 --- a/Package.swift +++ b/Package.swift @@ -43,7 +43,8 @@ let package = Package( .library(name: "SpecialErrorPages", targets: ["SpecialErrorPages"]), .library(name: "DuckPlayer", targets: ["DuckPlayer"]), .library(name: "PhishingDetection", targets: ["PhishingDetection"]), - .library(name: "Onboarding", targets: ["Onboarding"]) + .library(name: "Onboarding", targets: ["Onboarding"]), + .library(name: "TipKitUtils", targets: ["TipKitUtils"]) ], dependencies: [ .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "13.1.0"), @@ -429,6 +430,14 @@ let package = Package( .define("DEBUG", .when(configuration: .debug)) ] ), + .target( + name: "TipKitUtils", + dependencies: [ + ], + swiftSettings: [ + .define("DEBUG", .when(configuration: .debug)) + ] + ), // MARK: - Test Targets .testTarget( diff --git a/Sources/TipKitUtils/TipGrouping.swift b/Sources/TipKitUtils/TipGrouping.swift new file mode 100644 index 000000000..c133144e0 --- /dev/null +++ b/Sources/TipKitUtils/TipGrouping.swift @@ -0,0 +1,150 @@ +// +// TipGrouping.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 Foundation +import TipKit + +public protocol TipGrouping { + @available(iOS 17.0, *) + @MainActor + var currentTip: (any Tip)? { get } +} + +// This only compiles in Xcode 16 and needs to be re-enalbed once we move to it. +// Ref: https://app.asana.com/0/414235014887631/1208528787265444/f +// +// @available(iOS 18.0, *) +// extension TipGroup: TipGrouping {} + +/// A glorified no-op to be able to compile TipGrouping in iOS versions below 17. +/// +public struct EmptyTipGroup: TipGrouping { + @available(iOS 17.0, *) + public var currentTip: (any Tip)? { + return nil + } + + public init() {} +} + +/// Backport of TipKit's TipGroup to iOS 17. +/// +/// In iOS 17: this class should provide the same functionality as TipKit's `TipGroup`. +/// +@available(iOS 17.0, *) +@available(iOS, obsoleted: 18.0) +public struct LegacyTipGroup: TipGrouping { + + /// This is an implementation of TipGroup.Priority for iOS versions below 18. + /// + public enum Priority { + + /// Shows the first tip eligible for display. + case firstAvailable + + /// Shows an eligible tip when all of the previous tips have been [`invalidated`](doc:Tips/Status/invalidated(_:)). + case ordered + } + + private let priority: Priority + + @LegacyTipGroupBuilder + private let tipBuilder: () -> [any Tip] + + public init(_ priority: Priority = .ordered, @LegacyTipGroupBuilder _ tipBuilder: @escaping () -> [any Tip]) { + + self.priority = priority + self.tipBuilder = tipBuilder + } + + @MainActor + public var currentTip: (any Tip)? { + return tipBuilder().first { + switch $0.status { + case .available: + return true + case .invalidated: + return false + case .pending: + return priority == .ordered + @unknown default: + // Since this code is limited to iOS 17 and deprecated in iOS 18, we shouldn't + // need to worry about unknown cases. + fatalError("This path should never be called") + } + } + } + + @MainActor + var current: Any? { + currentTip + } +} + +@available(iOS 17.0, *) +@available(iOS, obsoleted: 18.0) +@resultBuilder public struct LegacyTipGroupBuilder { + public static func buildBlock() -> [Any] { + [] + } + + public static func buildBlock(_ components: any Tip...) -> [any Tip] { + components + } + + public static func buildPartialBlock(first: any Tip) -> [any Tip] { + [first] + } + + public static func buildPartialBlock(first: [any Tip]) -> [any Tip] { + first + } + + public static func buildPartialBlock(accumulated: [any Tip], next: any Tip) -> [any Tip] { + accumulated + [next] + } + + public static func buildPartialBlock(accumulated: [any Tip], next: [any Tip]) -> [any Tip] { + + accumulated + next + } + + public static func buildPartialBlock(first: Void) -> [any Tip] { + [] + } + + public static func buildPartialBlock(first: Never) -> [any Tip] { + // This will never be called + } + + public static func buildIf(_ element: [any Tip]?) -> [any Tip] { + element ?? [] + } + + public static func buildEither(first: [any Tip]) -> [any Tip] { + first + } + + public static func buildEither(second: [any Tip]) -> [any Tip] { + second + } + + public static func buildArray(_ components: [[any Tip]]) -> [any Tip] { + components.flatMap { $0 } + } +} diff --git a/Sources/TipKitUtils/TipKitController.swift b/Sources/TipKitUtils/TipKitController.swift new file mode 100644 index 000000000..07ab0cc5a --- /dev/null +++ b/Sources/TipKitUtils/TipKitController.swift @@ -0,0 +1,94 @@ +// +// TipKitController.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 Foundation +import os.log +import TipKit + +public protocol TipKitControlling { + @available(iOS 17.0, *) + func configureTipKit() + + @available(iOS 17.0, *) + func resetTipKitOnNextAppLaunch() +} + +typealias TipKitAppEventHandler = TipKitController + +public final class TipKitController { + + private let logger: Logger + private let userDefaults: UserDefaults + + private var resetTipKitOnNextLaunch: Bool { + get { + userDefaults.bool(forKey: "resetTipKitOnNextLaunch") + } + + set { + userDefaults.set(newValue, forKey: "resetTipKitOnNextLaunch") + } + } + + public init(logger: Logger, + userDefaults: UserDefaults) { + + self.logger = logger + self.userDefaults = userDefaults + } + + @available(iOS 17.0, *) + public func configureTipKit(_ configuration: [Tips.ConfigurationOption] = []) { + do { + if resetTipKitOnNextLaunch { + resetTipKit() + resetTipKitOnNextLaunch = false + } + + try Tips.configure(configuration) + + logger.debug("TipKit initialized") + } catch { + logger.error("Failed to initialize TipKit: \(error)") + } + } + + @available(iOS 17.0, *) + private func resetTipKit() { + do { + try Tips.resetDatastore() + + logger.debug("TipKit reset") + } catch { + logger.debug("Failed to reset TipKit: \(error)") + } + } + + /// Resets TipKit + /// + /// One thing that's not documented as of 2024-10-09 is that resetting TipKit must happen before it's configured. + /// When trying to reset it after it's configured we get `TipKit.TipKitError(value: TipKit.TipKitError.Value.tipsDatastoreAlreadyConfigured)`. + /// In order to make things work for us we set a user defaults value that ensures TipKit will be reset on next + /// app launch instead of directly trying to reset it here. + /// + @available(iOS 17.0, *) + public func resetTipKitOnNextAppLaunch() { + resetTipKitOnNextLaunch = true + logger.debug("TipKit will reset on next app launch") + } +} From c05ef03b3320ccecdace3b1d53d945313585c170 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Thu, 17 Oct 2024 14:30:53 +0200 Subject: [PATCH 2/3] Updated TipKitUtils code to compile for macOS too --- .../Combine/CurrentValuePublisher.swift | 36 +++++++++++++++++++ Sources/TipKitUtils/TipGrouping.swift | 14 ++++---- Sources/TipKitUtils/TipKitController.swift | 4 +-- 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 Sources/Common/Combine/CurrentValuePublisher.swift diff --git a/Sources/Common/Combine/CurrentValuePublisher.swift b/Sources/Common/Combine/CurrentValuePublisher.swift new file mode 100644 index 000000000..a250aea64 --- /dev/null +++ b/Sources/Common/Combine/CurrentValuePublisher.swift @@ -0,0 +1,36 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import Combine +import Foundation + +public final class CurrentValuePublisher { + + private(set) public var value: Output + private let wrappedPublisher: AnyPublisher + private var cancellable: AnyCancellable? + + public init(initialValue: Output, publisher: AnyPublisher) { + value = initialValue + wrappedPublisher = publisher + + subscribeToPublisherUpdates() + } + + private func subscribeToPublisherUpdates() { + cancellable = wrappedPublisher + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] value in + self?.value = value + } + } +} + +extension CurrentValuePublisher: Publisher { + public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + + wrappedPublisher.receive(subscriber: subscriber) + } + + +} diff --git a/Sources/TipKitUtils/TipGrouping.swift b/Sources/TipKitUtils/TipGrouping.swift index c133144e0..a4db2ccda 100644 --- a/Sources/TipKitUtils/TipGrouping.swift +++ b/Sources/TipKitUtils/TipGrouping.swift @@ -20,7 +20,7 @@ import Foundation import TipKit public protocol TipGrouping { - @available(iOS 17.0, *) + @available(iOS 17.0, macOS 14.0, *) @MainActor var currentTip: (any Tip)? { get } } @@ -34,7 +34,7 @@ public protocol TipGrouping { /// A glorified no-op to be able to compile TipGrouping in iOS versions below 17. /// public struct EmptyTipGroup: TipGrouping { - @available(iOS 17.0, *) + @available(iOS 17.0, macOS 14.0, *) public var currentTip: (any Tip)? { return nil } @@ -42,12 +42,13 @@ public struct EmptyTipGroup: TipGrouping { public init() {} } -/// Backport of TipKit's TipGroup to iOS 17. +/// Backport of TipKit's TipGroup to iOS 17 and macOS 14. /// -/// In iOS 17: this class should provide the same functionality as TipKit's `TipGroup`. +/// In iOS 17 and macOS 14: this class should provide the same functionality as TipKit's `TipGroup`. /// -@available(iOS 17.0, *) +@available(iOS 17.0, macOS 14.0, *) @available(iOS, obsoleted: 18.0) +@available(macOS, obsoleted: 15.0) public struct LegacyTipGroup: TipGrouping { /// This is an implementation of TipGroup.Priority for iOS versions below 18. @@ -96,8 +97,9 @@ public struct LegacyTipGroup: TipGrouping { } } -@available(iOS 17.0, *) +@available(iOS 17.0, macOS 14.0, *) @available(iOS, obsoleted: 18.0) +@available(macOS, obsoleted: 18.0) @resultBuilder public struct LegacyTipGroupBuilder { public static func buildBlock() -> [Any] { [] diff --git a/Sources/TipKitUtils/TipKitController.swift b/Sources/TipKitUtils/TipKitController.swift index 07ab0cc5a..14c20d729 100644 --- a/Sources/TipKitUtils/TipKitController.swift +++ b/Sources/TipKitUtils/TipKitController.swift @@ -52,7 +52,7 @@ public final class TipKitController { self.userDefaults = userDefaults } - @available(iOS 17.0, *) + @available(iOS 17.0, macOS 14.0, *) public func configureTipKit(_ configuration: [Tips.ConfigurationOption] = []) { do { if resetTipKitOnNextLaunch { @@ -68,7 +68,7 @@ public final class TipKitController { } } - @available(iOS 17.0, *) + @available(iOS 17.0, macOS 14.0, *) private func resetTipKit() { do { try Tips.resetDatastore() From a0617b16bee0acf22c51be281dec0385d54b446f Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Tue, 22 Oct 2024 19:32:01 +0200 Subject: [PATCH 3/3] Removes TipGrouping --- Sources/TipKitUtils/TipGrouping.swift | 152 -------------------------- 1 file changed, 152 deletions(-) delete mode 100644 Sources/TipKitUtils/TipGrouping.swift diff --git a/Sources/TipKitUtils/TipGrouping.swift b/Sources/TipKitUtils/TipGrouping.swift deleted file mode 100644 index a4db2ccda..000000000 --- a/Sources/TipKitUtils/TipGrouping.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// TipGrouping.swift -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// 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 Foundation -import TipKit - -public protocol TipGrouping { - @available(iOS 17.0, macOS 14.0, *) - @MainActor - var currentTip: (any Tip)? { get } -} - -// This only compiles in Xcode 16 and needs to be re-enalbed once we move to it. -// Ref: https://app.asana.com/0/414235014887631/1208528787265444/f -// -// @available(iOS 18.0, *) -// extension TipGroup: TipGrouping {} - -/// A glorified no-op to be able to compile TipGrouping in iOS versions below 17. -/// -public struct EmptyTipGroup: TipGrouping { - @available(iOS 17.0, macOS 14.0, *) - public var currentTip: (any Tip)? { - return nil - } - - public init() {} -} - -/// Backport of TipKit's TipGroup to iOS 17 and macOS 14. -/// -/// In iOS 17 and macOS 14: this class should provide the same functionality as TipKit's `TipGroup`. -/// -@available(iOS 17.0, macOS 14.0, *) -@available(iOS, obsoleted: 18.0) -@available(macOS, obsoleted: 15.0) -public struct LegacyTipGroup: TipGrouping { - - /// This is an implementation of TipGroup.Priority for iOS versions below 18. - /// - public enum Priority { - - /// Shows the first tip eligible for display. - case firstAvailable - - /// Shows an eligible tip when all of the previous tips have been [`invalidated`](doc:Tips/Status/invalidated(_:)). - case ordered - } - - private let priority: Priority - - @LegacyTipGroupBuilder - private let tipBuilder: () -> [any Tip] - - public init(_ priority: Priority = .ordered, @LegacyTipGroupBuilder _ tipBuilder: @escaping () -> [any Tip]) { - - self.priority = priority - self.tipBuilder = tipBuilder - } - - @MainActor - public var currentTip: (any Tip)? { - return tipBuilder().first { - switch $0.status { - case .available: - return true - case .invalidated: - return false - case .pending: - return priority == .ordered - @unknown default: - // Since this code is limited to iOS 17 and deprecated in iOS 18, we shouldn't - // need to worry about unknown cases. - fatalError("This path should never be called") - } - } - } - - @MainActor - var current: Any? { - currentTip - } -} - -@available(iOS 17.0, macOS 14.0, *) -@available(iOS, obsoleted: 18.0) -@available(macOS, obsoleted: 18.0) -@resultBuilder public struct LegacyTipGroupBuilder { - public static func buildBlock() -> [Any] { - [] - } - - public static func buildBlock(_ components: any Tip...) -> [any Tip] { - components - } - - public static func buildPartialBlock(first: any Tip) -> [any Tip] { - [first] - } - - public static func buildPartialBlock(first: [any Tip]) -> [any Tip] { - first - } - - public static func buildPartialBlock(accumulated: [any Tip], next: any Tip) -> [any Tip] { - accumulated + [next] - } - - public static func buildPartialBlock(accumulated: [any Tip], next: [any Tip]) -> [any Tip] { - - accumulated + next - } - - public static func buildPartialBlock(first: Void) -> [any Tip] { - [] - } - - public static func buildPartialBlock(first: Never) -> [any Tip] { - // This will never be called - } - - public static func buildIf(_ element: [any Tip]?) -> [any Tip] { - element ?? [] - } - - public static func buildEither(first: [any Tip]) -> [any Tip] { - first - } - - public static func buildEither(second: [any Tip]) -> [any Tip] { - second - } - - public static func buildArray(_ components: [[any Tip]]) -> [any Tip] { - components.flatMap { $0 } - } -}