Skip to content

Commit

Permalink
refactor(notifications): move to ergonomics
Browse files Browse the repository at this point in the history
Move typed notification NotificationCenter extension to Ergonomics.
Also fix some unused variable build warnings caused by holding on to
notification tokens in order to keep them from being deinited.
  • Loading branch information
flexjdev committed Feb 5, 2024
1 parent f346949 commit fdad874
Show file tree
Hide file tree
Showing 18 changed files with 442 additions and 278 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import GSMessages
import ProtonCoreUIFoundations

import Domain
import Ergonomics
import LocalFeatureFlags
import Strings
import Home
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@

import Cocoa
import Dependencies

import ProtonCoreFeatureFlags
import ProtonCoreUtilities

import Ergonomics
import LegacyCommon
import VPNShared
import ProtonCoreUtilities
import ProtonCoreFeatureFlags

enum SessionStatus {
case notEstablished
Expand Down Expand Up @@ -337,7 +340,7 @@ final class AppSessionManagerImplementation: AppSessionRefresherImplementation,

// End of the logout logic

private func post(notification: any StrongNotification) {
private func post(notification: any TypedNotification) {
DispatchQueue.main.async {
NotificationCenter.default.post(notification, object: self)
}
Expand Down Expand Up @@ -394,8 +397,8 @@ final class AppSessionManagerImplementation: AppSessionRefresherImplementation,
}
}

struct SessionChanged: StrongNotification {
static var name: Notification.Name { Notification.Name("AppSessionManagerSessionChanged") }
struct SessionChanged: TypedNotification {
static let name = Notification.Name("AppSessionManagerSessionChanged")
let data: SessionChangeData

enum SessionChangeData {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@

import Cocoa
import os
import LegacyCommon

import ComposableArchitecture

import Ergonomics
import PMLogger
import LegacyCommon
import VPNShared
import ComposableArchitecture


protocol NavigationServiceFactory {
func makeNavigationService() -> NavigationService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
//

import Cocoa

import Dependencies

import Ergonomics
import LegacyCommon

protocol ProfilesMenuViewModelFactory {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
//

import Cocoa

import Ergonomics
import LegacyCommon

protocol ProtonVpnMenuViewModelFactory {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@

import Cocoa
import Dependencies

import Domain
import Ergonomics
import Strings
import Theme

import LegacyCommon
import Theme
import Strings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
//

import Cocoa

import Ergonomics
import LegacyCommon

protocol StatusMenuWindowModelFactory {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Foundation
import Dependencies

import Domain
import Ergonomics
import Strings
import Home
import Modals_macOS
Expand Down
9 changes: 6 additions & 3 deletions libraries/Foundations/Ergonomics/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@ let package = Package(
),
],
dependencies: [
.package(path: "../../../external/protoncore")
.package(path: "../../../external/protoncore"),
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", .upToNextMajor(from: "1.0.0")),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Ergonomics",
dependencies: [
.product(name: "ProtonCoreUtilities", package: "protoncore")
.product(name: "ProtonCoreUtilities", package: "protoncore"),
.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay")
]
),
.testTarget(
name: "ErgonomicsTests",
dependencies: ["Ergonomics"]),
dependencies: ["Ergonomics"]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//
// Created on 08/03/2023.
//
// Copyright (c) 2023 Proton AG
//
// ProtonVPN is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonVPN is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.

import Foundation
import NotificationCenter

import XCTestDynamicOverlay

/// Automatically handles transferring a payload using `NotificationCenter`.
///
/// Using the `object` argument of the default `NotificationCenter` API is a common pitfall among Swift developers,
/// partly due to the added complexity of the intended payload delivery mechanism being a dictionary. Using `object` in
/// this way also prevents the ability to selectively listen to notifications sent from specific objects.
///
/// `TypedNotification` attempts to provide a more ergonomic interface, with some added safety checks in place to catch
/// programmer errors.
///
/// ```
/// struct StatsChanged: TypedNotification {
/// static let name = Notification.Name("ch.protonvpn.feature.statschanged")
/// let data: Stats
/// }
///
/// struct Stats {
/// let bytesSent: Int64
/// let bytesReceived: Int64
/// }
/// ```
///
/// Check `TypedNotificationTests` and the related `NotificationCenter.addObserver` extensions for more example usage.
public protocol TypedNotification<T> {
associatedtype T
static var name: Notification.Name { get }
var data: T { get }
}

extension TypedNotification {
static var dataKey: String { "ch.protonvpn.notificationcenter.notificationdata" }
}

protocol EmptyTypedNotification: TypedNotification<Void> { }
extension EmptyTypedNotification {
var data: Void { return }
}

/// Wraps the observer token received from NotificationCenter and unregisters it when deallocated
public final class NotificationToken {
private let notificationCenter: NotificationCenter
private let observer: NSObjectProtocol
private let name: NSNotification.Name?
private let object: Any?

init(notificationCenter: NotificationCenter, observer: NSObjectProtocol, name: Notification.Name?, object: Any?) {
self.notificationCenter = notificationCenter
self.observer = observer
self.name = name
self.object = object
}

deinit {
notificationCenter.removeObserver(observer, name: name, object: object)
}
}

extension NotificationCenter {

public func post<T>(_ notification: some TypedNotification<T>, object: Any?) {
let userInfo = [type(of: notification).dataKey: notification.data]
post(name: type(of: notification).name, object: object, userInfo: userInfo)
}

/// Register a block to be executed when a notification with the given name is posted by the specified object, or
/// any object if nil is provided.
///
/// The notification center copies the handler, and strongly holds it until NotificationToken is deinited.
public func addObserver(
for notificationName: Notification.Name,
queue: OperationQueue? = nil,
object: Any?,
handler: @escaping (Notification) -> Void
) -> NotificationToken {
let observer = addObserver(forName: notificationName, object: object, queue: queue, using: handler)
return NotificationToken(notificationCenter: self, observer: observer, name: notificationName, object: object)
}

/// Register a block to be executed when the specified object, (or any object if nil is provided) posts a
/// notification matching any of the names given.
///
/// The notification center copies the block for each notification and strongly holds each separately while the
/// corresponding token exists.
public func addObservers(
for notifications: [Notification.Name],
queue: OperationQueue? = nil,
object: Any?,
handler: @escaping (Notification) -> Void
) -> [NotificationToken] {
return notifications.map { addObserver(for: $0, queue: queue, object: object, handler: handler) }
}

/// Similarly to the above overloads, returns a `NotificationToken` which is used to control the lifetime of the
/// observer that handles responding to the subscribed notification.
///
/// To avoid creating a retain cycle, make sure the handler does not hold a strong reference to the object which
/// holds a strong reference to the returned `NotificationToken`.
///
/// ```
/// class StatisticsManager {
///
/// var token: NotificationToken?
///
/// func startObserving() {
/// let nc = NotificationCenter.default
/// // Creates retain cycle. `token` has to be manually set to nil, otherwise `StatisticsManager` will be
/// // forever held in memory
/// token = nc.addObserver(for: StatsChanged.self, object: self, handler: handleStatsChanged)
///
/// // `token` is automatically released whenever `StatisticsManager` goes out of scope.
/// token = nc.addObserver(for: StatsChanged.self, object: self) { [weak self] stats in
/// self?.handleSessionChanged(stats: stats)
/// }
/// }
///
/// func handleStatsChanged(stats: Stats) { ... }
/// }
/// ```
///
/// The first argument of this function should conventionally be `forNotificationsOfType`, but one of the strengths
/// of this extension is the ergonomics/conciseness of the API, when compared to the verbosity of the default
/// NotificationCenter APIs
public func addObserver<Notification, T>(
for _: Notification.Type,
queue: OperationQueue? = nil,
object: Any?,
handler: @escaping (T) -> Void
) -> NotificationToken where Notification: TypedNotification<T> {
return addObserver(for: Notification.name, queue: queue, object: object) { notification in
guard let data = notification.userInfo?[Notification.dataKey] else {
XCTFail("Expected object of type \(T.self) stored under key: \(Notification.dataKey), got nil")
return
}
guard let data = data as? T else {
XCTFail("Expected object of type \(T.self) stored under key: \(Notification.dataKey), got \(String(describing: data))")
return
}

handler(data)
}
}
}
24 changes: 0 additions & 24 deletions libraries/Foundations/Ergonomics/Tests/Ergonomics.xctestplan

This file was deleted.

Loading

0 comments on commit fdad874

Please sign in to comment.