Address UI race conditions (#229)

* Make some managers concurrency-safe

- IntentsManager: @MainActor, non-shared, continuation

- SSIDReader: @MainActor, continuation

- Reviewer: main queue, non-shared

* Review wrong use of Concurrency framework

There were background thread calls e.g. in VPNToggle, because
ProfileManager was used inside a VPNManager async call.

Annotate @MainActor wherever a Task involves UI.

* Make main managers MainActor

* Apply MainActor to Mac menus

* [ci skip] Update CHANGELOG

* Set MainActor consistently on Mac menu view models
This commit is contained in:
Davide De Rosa 2022-10-13 08:53:50 +02:00 committed by GitHub
parent 54dc8a2556
commit 5627e6c4a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 138 additions and 122 deletions

View File

@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Oeck provider is available again to free users. - Oeck provider is available again to free users.
- Randomic crashes on profile updates. - Randomic crashes on profile updates. [#229](https://github.com/passepartoutvpn/passepartout-apple/pull/229)
## 2.0.0 (2022-10-02) ## 2.0.0 (2022-10-02)

View File

@ -32,11 +32,3 @@ extension AppContext {
extension ProductManager { extension ProductManager {
static let shared = AppContext.shared.productManager static let shared = AppContext.shared.productManager
} }
extension IntentsManager {
static let shared = AppContext.shared.intentsManager
}
extension Reviewer {
static let shared = AppContext.shared.reviewer
}

View File

@ -27,14 +27,13 @@ import Foundation
import Combine import Combine
import PassepartoutLibrary import PassepartoutLibrary
@MainActor
class AppContext { class AppContext {
private let logManager: LogManager private let logManager: LogManager
let productManager: ProductManager private let reviewer: Reviewer
let intentsManager: IntentsManager let productManager: ProductManager
let reviewer: Reviewer
private var cancellables: Set<AnyCancellable> = [] private var cancellables: Set<AnyCancellable> = []
@ -45,32 +44,33 @@ class AppContext {
logManager.configureLogging() logManager.configureLogging()
pp_log.info("Logging to: \(logManager.logFile!)") pp_log.info("Logging to: \(logManager.logFile!)")
reviewer = Reviewer()
reviewer.eventCountBeforeRating = Constants.Rating.eventCount
productManager = ProductManager( productManager = ProductManager(
appType: Constants.InApp.appType, appType: Constants.InApp.appType,
buildProducts: Constants.InApp.buildProducts buildProducts: Constants.InApp.buildProducts
) )
intentsManager = IntentsManager()
reviewer = Reviewer()
reviewer.eventCountBeforeRating = Constants.Rating.eventCount
// post // post
configureObjects(coreContext: coreContext) configureObjects(coreContext: coreContext)
} }
private func configureObjects(coreContext: CoreContext) { private func configureObjects(coreContext: CoreContext) {
coreContext.vpnManager.isOnDemandRulesSupported = {
self.isEligibleForOnDemandRules()
}
coreContext.vpnManager.currentState.$vpnStatus coreContext.vpnManager.currentState.$vpnStatus
.removeDuplicates() .removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { .sink {
if $0 == .connected { if $0 == .connected {
pp_log.info("VPN successful connection, report to Reviewer") pp_log.info("VPN successful connection, report to Reviewer")
self.reviewer.reportEvent() self.reviewer.reportEvent()
} }
}.store(in: &cancellables) }.store(in: &cancellables)
coreContext.vpnManager.isOnDemandRulesSupported = {
self.isEligibleForOnDemandRules()
}
} }
// eligibility: ignore network settings if ineligible // eligibility: ignore network settings if ineligible

View File

@ -28,6 +28,7 @@ import Intents
import IntentsUI import IntentsUI
import Combine import Combine
@MainActor
class IntentsManager: NSObject, ObservableObject { class IntentsManager: NSObject, ObservableObject {
@Published private(set) var isReloadingShortcuts = false @Published private(set) var isReloadingShortcuts = false
@ -35,28 +36,26 @@ class IntentsManager: NSObject, ObservableObject {
let shouldDismissIntentView = PassthroughSubject<Void, Never>() let shouldDismissIntentView = PassthroughSubject<Void, Never>()
private var continuation: CheckedContinuation<[INVoiceShortcut], Never>?
override init() { override init() {
super.init() super.init()
reloadShortcuts() Task {
await reloadShortcuts()
}
} }
func reloadShortcuts() { func reloadShortcuts() async {
isReloadingShortcuts = true isReloadingShortcuts = true
INVoiceShortcutCenter.shared.getAllVoiceShortcuts { vs, error in do {
if let error = error { let vs = try await INVoiceShortcutCenter.shared.allVoiceShortcuts()
assertionFailure("Unable to fetch existing shortcuts: \(error)") shortcuts = vs.reduce(into: [UUID: Shortcut]()) {
DispatchQueue.main.async {
self.isReloadingShortcuts = false
}
return
}
let shortcuts = (vs ?? []).reduce(into: [UUID: Shortcut]()) {
$0[$1.identifier] = Shortcut($1) $0[$1.identifier] = Shortcut($1)
} }
DispatchQueue.main.async { isReloadingShortcuts = false
self.shortcuts = shortcuts } catch {
self.isReloadingShortcuts = false assertionFailure("Unable to fetch existing shortcuts: \(error)")
} isReloadingShortcuts = false
} }
} }
} }
@ -93,9 +92,7 @@ extension IntentsManager: INUIEditVoiceShortcutViewControllerDelegate {
// so damn it, reload manually after a delay // so damn it, reload manually after a delay
Task { Task {
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxReloadEditedShortcut) await Task.maybeWait(forMilliseconds: Constants.Delays.xxxReloadEditedShortcut)
await MainActor.run { await reloadShortcuts()
reloadShortcuts()
}
} }
} }

View File

@ -32,6 +32,7 @@ class MacBundle {
private lazy var bridgeDelegate = MacBundleDelegate(bundle: self) private lazy var bridgeDelegate = MacBundleDelegate(bundle: self)
@MainActor
func configure() { func configure() {
guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent(Constants.Plugins.macBridgeName) else { guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent(Constants.Plugins.macBridgeName) else {
fatalError("Unable to find Mac bundle in plugins") fatalError("Unable to find Mac bundle in plugins")

View File

@ -28,14 +28,17 @@ import Foundation
class MacBundleDelegate: MacMenuDelegate { class MacBundleDelegate: MacMenuDelegate {
private weak var bundle: MacBundle? private weak var bundle: MacBundle?
@MainActor
var profileManager: LightProfileManager { var profileManager: LightProfileManager {
DefaultLightProfileManager() DefaultLightProfileManager()
} }
@MainActor
var providerManager: LightProviderManager { var providerManager: LightProviderManager {
DefaultLightProviderManager() DefaultLightProviderManager()
} }
@MainActor
var vpnManager: LightVPNManager { var vpnManager: LightVPNManager {
DefaultLightVPNManager() DefaultLightVPNManager()
} }

View File

@ -103,7 +103,6 @@ class DefaultLightProviderManager: LightProviderManager {
.map(DefaultLightProviderCategory.init) .map(DefaultLightProviderCategory.init)
} }
@MainActor
func downloadIfNeeded(_ name: String, vpnProtocol: String) { func downloadIfNeeded(_ name: String, vpnProtocol: String) {
guard let vpnProtocolType = VPNProtocolType(rawValue: vpnProtocol) else { guard let vpnProtocolType = VPNProtocolType(rawValue: vpnProtocol) else {
fatalError("Unrecognized VPN protocol: \(vpnProtocol)") fatalError("Unrecognized VPN protocol: \(vpnProtocol)")

View File

@ -64,28 +64,24 @@ class DefaultLightVPNManager: LightVPNManager {
}.store(in: &subscriptions) }.store(in: &subscriptions)
} }
@MainActor
func connect(with profileId: UUID) { func connect(with profileId: UUID) {
Task { Task {
try? await vpnManager.connect(with: profileId) try? await vpnManager.connect(with: profileId)
} }
} }
@MainActor
func connect(with profileId: UUID, to serverId: String) { func connect(with profileId: UUID, to serverId: String) {
Task { Task {
try? await vpnManager.connect(with: profileId, toServer: serverId) try? await vpnManager.connect(with: profileId, toServer: serverId)
} }
} }
@MainActor
func disconnect() { func disconnect() {
Task { Task {
await vpnManager.disable() await vpnManager.disable()
} }
} }
@MainActor
func toggle() { func toggle() {
Task { Task {
if !isEnabled { if !isEnabled {
@ -96,7 +92,6 @@ class DefaultLightVPNManager: LightVPNManager {
} }
} }
@MainActor
func reconnect() { func reconnect() {
Task { Task {
await vpnManager.reconnect() await vpnManager.reconnect()

View File

@ -159,7 +159,7 @@ extension GenericCreditsView {
guard content == nil else { guard content == nil else {
return return
} }
Task { Task { @MainActor in
withAnimation { withAnimation {
do { do {
content = try String(contentsOf: url) content = try String(contentsOf: url)

View File

@ -52,6 +52,7 @@ extension AddHostView {
profileName = url.normalizedFilename profileName = url.normalizedFilename
} }
@MainActor
mutating func processURL( mutating func processURL(
_ url: URL, _ url: URL,
with profileManager: ProfileManager, with profileManager: ProfileManager,
@ -96,6 +97,7 @@ extension AddHostView {
} }
} }
@MainActor
mutating func addProcessedProfile(to profileManager: ProfileManager) -> Bool { mutating func addProcessedProfile(to profileManager: ProfileManager) -> Bool {
guard !processedProfile.isPlaceholder else { guard !processedProfile.isPlaceholder else {
assertionFailure("Saving profile without processing first?") assertionFailure("Saving profile without processing first?")

View File

@ -58,9 +58,7 @@ struct AddProfileMenu: View {
} label: { } label: {
Label(L10n.Global.Strings.provider, systemImage: themeProviderImage) Label(L10n.Global.Strings.provider, systemImage: themeProviderImage)
} }
Button { Button(action: presentHostFileImporter) {
presentHostFileImporter()
} label: {
Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage) Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage)
} }
// Button { // Button {
@ -146,7 +144,7 @@ extension AddProfileMenu {
// //
// https://stackoverflow.com/questions/66965471/swiftui-fileimporter-modifier-not-updating-binding-when-dismissed-by-tapping // https://stackoverflow.com/questions/66965471/swiftui-fileimporter-modifier-not-updating-binding-when-dismissed-by-tapping
isHostFileImporterPresented = false isHostFileImporterPresented = false
Task { Task { @MainActor in
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter) await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter)
isHostFileImporterPresented = true isHostFileImporterPresented = true
} }

View File

@ -27,8 +27,6 @@ import Foundation
import PassepartoutLibrary import PassepartoutLibrary
extension AddProviderView { extension AddProviderView {
@MainActor
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
enum PendingOperation { enum PendingOperation {
case index case index
@ -75,18 +73,16 @@ extension AddProviderView {
metadata.name, metadata.name,
vpnProtocol: selectedVPNProtocol vpnProtocol: selectedVPNProtocol
) else { ) else {
Task { selectProviderAfterFetchingInfrastructure(metadata, providerManager)
await selectProviderAfterFetchingInfrastructure(metadata, providerManager)
}
return return
} }
doSelectProvider(metadata, server) doSelectProvider(metadata, server)
} }
private func selectProviderAfterFetchingInfrastructure(_ metadata: ProviderMetadata, _ providerManager: ProviderManager) async { private func selectProviderAfterFetchingInfrastructure(_ metadata: ProviderMetadata, _ providerManager: ProviderManager) {
errorMessage = nil errorMessage = nil
pendingOperation = .provider(metadata.name) pendingOperation = .provider(metadata.name)
Task { Task { @MainActor in
do { do {
try await providerManager.fetchProviderPublisher( try await providerManager.fetchProviderPublisher(
withName: metadata.name, withName: metadata.name,
@ -117,7 +113,7 @@ extension AddProviderView {
func updateIndex(_ providerManager: ProviderManager) { func updateIndex(_ providerManager: ProviderManager) {
errorMessage = nil errorMessage = nil
pendingOperation = .index pendingOperation = .index
Task { Task { @MainActor in
do { do {
try await providerManager.fetchProvidersIndexPublisher( try await providerManager.fetchProvidersIndexPublisher(
priority: .remoteThenBundle priority: .remoteThenBundle
@ -153,6 +149,7 @@ extension AddProviderView.NameView {
profileName = metadata.fullName profileName = metadata.fullName
} }
@MainActor
mutating func addProfile( mutating func addProfile(
_ profile: Profile, _ profile: Profile,
to profileManager: ProfileManager, to profileManager: ProfileManager,

View File

@ -34,11 +34,7 @@ extension OnDemandView {
var body: some View { var body: some View {
EditableTextList(elements: allSSIDs, allowsDuplicates: false, mapping: mapElements) { text in EditableTextList(elements: allSSIDs, allowsDuplicates: false, mapping: mapElements) { text in
reader.requestCurrentSSID { requestSSID(text)
if !withSSIDs.keys.contains($0) {
text.wrappedValue = $0
}
}
} textField: { } textField: {
ssidRow(callback: $0) ssidRow(callback: $0)
} addLabel: { } addLabel: {
@ -74,6 +70,15 @@ extension OnDemandView {
onCommit: callback.onCommit onCommit: callback.onCommit
).themeValidSSID(callback.text.wrappedValue) ).themeValidSSID(callback.text.wrappedValue)
} }
private func requestSSID(_ text: Binding<String>) {
Task { @MainActor in
let ssid = try await reader.requestCurrentSSID()
if !withSSIDs.keys.contains(ssid) {
text.wrappedValue = ssid
}
}
}
} }
} }

View File

@ -41,9 +41,8 @@ extension OrganizerView {
if !profileManager.hasProfiles { if !profileManager.hasProfiles {
emptyView emptyView
} }
}.onAppear { }.onAppear(perform: performMigrationsIfNeeded)
performMigrationsIfNeeded() .onReceive(profileManager.didCreateProfile) {
}.onReceive(profileManager.didCreateProfile) {
profileManager.currentProfileId = $0.id profileManager.currentProfileId = $0.id
} }
} }
@ -141,7 +140,7 @@ extension OrganizerView {
} }
private func performMigrationsIfNeeded() { private func performMigrationsIfNeeded() {
Task { Task { @MainActor in
UpgradeManager.shared.doMigrations(profileManager) UpgradeManager.shared.doMigrations(profileManager)
} }
} }

View File

@ -87,7 +87,7 @@ extension OrganizerView {
switch phase { switch phase {
case .active: case .active:
if productManager.hasRefunded() { if productManager.hasRefunded() {
Task { Task { @MainActor in
await vpnManager.uninstall() await vpnManager.uninstall()
} }
} }

View File

@ -102,7 +102,7 @@ extension OrganizerView {
assertionFailure("Empty URLs from file importer?") assertionFailure("Empty URLs from file importer?")
return return
} }
Task { Task { @MainActor in
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter) await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter)
addProfileModalType = .addHost(url, false) addProfileModalType = .addHost(url, false)
} }

View File

@ -138,7 +138,7 @@ extension ProfileView {
} }
private func uninstallVPN() { private func uninstallVPN() {
Task { Task { @MainActor in
await vpnManager.uninstall() await vpnManager.uninstall()
} }
} }

View File

@ -127,7 +127,7 @@ extension ProfileView {
private func refreshInfrastructure() { private func refreshInfrastructure() {
isRefreshingInfrastructure = true isRefreshingInfrastructure = true
Task { Task { @MainActor in
try await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async() try await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async()
isRefreshingInfrastructure = false isRefreshingInfrastructure = false
} }

View File

@ -43,7 +43,7 @@ struct ShortcutsView: View {
} }
} }
@ObservedObject private var intentsManager: IntentsManager @StateObject private var intentsManager = IntentsManager()
@Environment(\.presentationMode) private var presentationMode @Environment(\.presentationMode) private var presentationMode
@ -56,7 +56,6 @@ struct ShortcutsView: View {
@State private var pendingShortcut: INShortcut? @State private var pendingShortcut: INShortcut?
init(target: Profile) { init(target: Profile) {
intentsManager = .shared
self.target = target self.target = target
} }
@ -69,11 +68,7 @@ struct ShortcutsView: View {
}.toolbar { }.toolbar {
themeCloseItem(presentationMode: presentationMode) themeCloseItem(presentationMode: presentationMode)
}.sheet(item: $modalType, content: presentedModal) }.sheet(item: $modalType, content: presentedModal)
.themeAnimation(on: intentsManager.isReloadingShortcuts)
// reloading
.onAppear {
intentsManager.reloadShortcuts()
}.themeAnimation(on: intentsManager.isReloadingShortcuts)
// IntentsUI // IntentsUI
.onReceive(intentsManager.shouldDismissIntentView) { _ in .onReceive(intentsManager.shouldDismissIntentView) { _ in

View File

@ -77,11 +77,10 @@ struct VPNToggle: View {
} }
private func enableVPN() { private func enableVPN() {
Task { Task { @MainActor in
canToggle = false canToggle = false
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(rateLimit)) { await Task.maybeWait(forMilliseconds: rateLimit)
canToggle = true canToggle = true
}
do { do {
let profile = try await vpnManager.connect(with: profileId) let profile = try await vpnManager.connect(with: profileId)
donateIntents(withProfile: profile) donateIntents(withProfile: profile)
@ -93,7 +92,7 @@ struct VPNToggle: View {
} }
private func disableVPN() { private func disableVPN() {
Task { Task { @MainActor in
canToggle = false canToggle = false
await vpnManager.disable() await vpnManager.disable()
canToggle = true canToggle = true

View File

@ -27,8 +27,6 @@ import Foundation
import PassepartoutLibrary import PassepartoutLibrary
extension CoreContext { extension CoreContext {
@MainActor
static let shared = CoreContext(store: UserDefaultsStore(defaults: .standard)) static let shared = CoreContext(store: UserDefaultsStore(defaults: .standard))
} }
@ -49,5 +47,7 @@ extension VPNManager {
} }
extension ObservableVPNState { extension ObservableVPNState {
@MainActor
static let shared = CoreContext.shared.vpnManager.currentState static let shared = CoreContext.shared.vpnManager.currentState
} }

View File

@ -27,6 +27,7 @@ import Foundation
import Combine import Combine
import PassepartoutLibrary import PassepartoutLibrary
@MainActor
class CoreContext { class CoreContext {
let store: KeyValueStore let store: KeyValueStore
@ -52,7 +53,6 @@ class CoreContext {
private var cancellables: Set<AnyCancellable> = [] private var cancellables: Set<AnyCancellable> = []
@MainActor
init(store: KeyValueStore) { init(store: KeyValueStore) {
self.store = store self.store = store

View File

@ -25,6 +25,7 @@
import Foundation import Foundation
@MainActor
@objc @objc
public protocol MacMenu { public protocol MacMenu {
var delegate: MacMenuDelegate? { get set } var delegate: MacMenuDelegate? { get set }

View File

@ -46,6 +46,7 @@ extension LightProfile {
} }
} }
@MainActor
@objc @objc
public protocol LightProfileManager { public protocol LightProfileManager {
var hasProfiles: Bool { get } var hasProfiles: Bool { get }

View File

@ -56,6 +56,7 @@ public protocol LightProviderServer {
var serverId: String { get } var serverId: String { get }
} }
@MainActor
@objc @objc
public protocol LightProviderManager { public protocol LightProviderManager {
var delegate: LightProviderManagerDelegate? { get set } var delegate: LightProviderManagerDelegate? { get set }

View File

@ -36,6 +36,7 @@ public enum LightVPNStatus: Int {
case disconnected case disconnected
} }
@MainActor
@objc @objc
public protocol LightVPNManager { public protocol LightVPNManager {
var isEnabled: Bool { get } var isEnabled: Bool { get }

View File

@ -26,6 +26,8 @@
import Foundation import Foundation
extension HostProfileItem { extension HostProfileItem {
@MainActor
class ViewModel { class ViewModel {
let profile: LightProfile let profile: LightProfile
@ -41,7 +43,9 @@ extension HostProfileItem {
} }
deinit { deinit {
vpnManager.removeDelegate(withIdentifier: profile.id.uuidString) Task { @MainActor in
vpnManager.removeDelegate(withIdentifier: profile.id.uuidString)
}
} }
@objc func connectTo() { @objc func connectTo() {

View File

@ -28,6 +28,8 @@ import Combine
import ServiceManagement import ServiceManagement
extension LaunchOnLoginItem { extension LaunchOnLoginItem {
@MainActor
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
let title: String let title: String

View File

@ -27,6 +27,8 @@ import Foundation
import AppKit import AppKit
extension PassepartoutMenu { extension PassepartoutMenu {
@MainActor
class StatusButton { class StatusButton {
private lazy var statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private lazy var statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@ -50,7 +52,9 @@ extension PassepartoutMenu {
} }
deinit { deinit {
vpnManager.removeDelegate(withIdentifier: "PassepartoutMenu") Task { @MainActor in
vpnManager.removeDelegate(withIdentifier: "PassepartoutMenu")
}
} }
func install(systemMenu: SystemMenu) { func install(systemMenu: SystemMenu) {

View File

@ -26,6 +26,7 @@
import Foundation import Foundation
import AppKit import AppKit
@MainActor
class PassepartoutMenu { class PassepartoutMenu {
private let macMenuDelegate: MacMenuDelegate private let macMenuDelegate: MacMenuDelegate

View File

@ -26,6 +26,8 @@
import Foundation import Foundation
extension ProviderLocationItem { extension ProviderLocationItem {
@MainActor
class ViewModel { class ViewModel {
private let profile: LightProfile private let profile: LightProfile

View File

@ -26,6 +26,8 @@
import Foundation import Foundation
extension ProviderProfileItem { extension ProviderProfileItem {
@MainActor
class ViewModel { class ViewModel {
let profile: LightProfile let profile: LightProfile
@ -44,7 +46,9 @@ extension ProviderProfileItem {
} }
deinit { deinit {
vpnManager.removeDelegate(withIdentifier: profile.id.uuidString) Task { @MainActor in
vpnManager.removeDelegate(withIdentifier: profile.id.uuidString)
}
} }
private var providerName: String { private var providerName: String {

View File

@ -26,6 +26,8 @@
import Foundation import Foundation
extension ProviderServerItem { extension ProviderServerItem {
@MainActor
class ViewModel { class ViewModel {
private let profile: LightProfile private let profile: LightProfile

View File

@ -27,6 +27,8 @@ import Foundation
import Combine import Combine
extension VPNItemGroup { extension VPNItemGroup {
@MainActor
class ViewModel { class ViewModel {
private let vpnManager: LightVPNManager private let vpnManager: LightVPNManager
@ -51,7 +53,9 @@ extension VPNItemGroup {
} }
deinit { deinit {
vpnManager.removeDelegate(withIdentifier: "VPNItemGroup") Task { @MainActor in
vpnManager.removeDelegate(withIdentifier: "VPNItemGroup")
}
} }
var toggleTitle: String { var toggleTitle: String {

View File

@ -27,6 +27,8 @@ import Foundation
import AppKit import AppKit
extension VisibilityItem { extension VisibilityItem {
@MainActor
class ViewModel { class ViewModel {
private let transformer: ObservableProcessTransformer private let transformer: ObservableProcessTransformer

View File

@ -32,5 +32,6 @@ class PassepartoutMac: NSObject, MacBridge {
let utils: MacUtils = DefaultMacUtils() let utils: MacUtils = DefaultMacUtils()
@MainActor
let menu: MacMenu = DefaultMacMenu() let menu: MacMenu = DefaultMacMenu()
} }

View File

@ -26,6 +26,7 @@
import Foundation import Foundation
import AppKit import AppKit
@MainActor
protocol ItemGroup { protocol ItemGroup {
func asMenuItems(withParent parent: NSMenu) -> [NSMenuItem] func asMenuItems(withParent parent: NSMenu) -> [NSMenuItem]
} }

View File

@ -26,6 +26,7 @@
import Foundation import Foundation
import AppKit import AppKit
@MainActor
protocol SystemMenu { protocol SystemMenu {
var asMenu: NSMenu { get } var asMenu: NSMenu { get }
} }

View File

@ -29,6 +29,7 @@ import SwiftyBeaver
import PassepartoutCore import PassepartoutCore
import PassepartoutUtils import PassepartoutUtils
@MainActor
public final class UpgradeManager: ObservableObject { public final class UpgradeManager: ObservableObject {
// MARK: Initialization // MARK: Initialization

View File

@ -30,6 +30,7 @@ import PassepartoutCore
import PassepartoutUtils import PassepartoutUtils
import PassepartoutProviders import PassepartoutProviders
@MainActor
public final class ProfileManager: ObservableObject { public final class ProfileManager: ObservableObject {
public typealias ProfileEx = (profile: Profile, isReady: Bool) public typealias ProfileEx = (profile: Profile, isReady: Bool)
@ -292,10 +293,8 @@ extension ProfileManager {
currentProfile.isLoading = true currentProfile.isLoading = true
Task { Task {
try await makeProfileReady(profile) try await makeProfileReady(profile)
await MainActor.run { currentProfile.value = profile
currentProfile.value = profile currentProfile.isLoading = false
currentProfile.isLoading = false
}
} }
} }
} }

View File

@ -25,48 +25,50 @@
import Foundation import Foundation
import CoreLocation import CoreLocation
import Combine
public class SSIDReader: NSObject, ObservableObject, CLLocationManagerDelegate { @MainActor
public class SSIDReader: NSObject, ObservableObject {
private let manager = CLLocationManager() private let manager = CLLocationManager()
private let publisher = PassthroughSubject<String, Never>()
private var cancellables: Set<AnyCancellable> = [] private var continuation: CheckedContinuation<String, Error>?
public func requestCurrentSSID(onSSID: @escaping (String) -> Void) { private func currentSSID() async -> String {
publisher await Utils.currentWifiSSID() ?? ""
.sink(receiveValue: onSSID) }
.store(in: &cancellables)
public func requestCurrentSSID() async throws -> String {
switch manager.authorizationStatus { switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse, .denied: case .authorizedAlways, .authorizedWhenInUse, .denied:
notifyCurrentSSID() return await currentSSID()
return
default: default:
manager.delegate = self return try await withCheckedThrowingContinuation { continuation in
manager.requestWhenInUseAuthorization() self.continuation = continuation
}
} manager.delegate = self
manager.requestWhenInUseAuthorization()
private func notifyCurrentSSID() {
Task {
let currentSSID = await Utils.currentWifiSSID() ?? ""
await MainActor.run {
publisher.send(currentSSID)
cancellables.removeAll()
} }
} }
} }
}
extension SSIDReader: CLLocationManagerDelegate {
public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus { switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways, .denied: case .authorizedWhenInUse, .authorizedAlways, .denied:
notifyCurrentSSID() Task {
continuation?.resume(returning: await currentSSID())
continuation = nil
}
default: default:
cancellables.removeAll() continuation?.resume(with: .success(""))
continuation = nil
} }
} }
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
} }

View File

@ -70,7 +70,6 @@ public class TunnelKitVPNManagerStrategy<VPNType: VPN>: VPNManagerStrategy where
private var currentBundleIdentifier: String? private var currentBundleIdentifier: String?
@MainActor
public init( public init(
appGroup: String, appGroup: String,
tunnelBundleIdentifier: @escaping (VPNProtocolType) -> String, tunnelBundleIdentifier: @escaping (VPNProtocolType) -> String,

View File

@ -32,6 +32,7 @@ import PassepartoutProfiles
import PassepartoutProviders import PassepartoutProviders
import PassepartoutUtils import PassepartoutUtils
@MainActor
public final class VPNManager: ObservableObject { public final class VPNManager: ObservableObject {
// MARK: Initialization // MARK: Initialization