diff --git a/CHANGES.md b/CHANGES.md index dc820676..8154a7dd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,7 @@ - Avoid using cached token endpoints from OIDAuthState #487 - macOS: Point the user to the OS' please-enable-notification prompt #474 - Improve error alerts #489 +- macOS: Autoconnect on launch at login if machine was shutdown with VPN on #478 ## 3.0.6 diff --git a/EduVPN/AppDelegate.swift b/EduVPN/AppDelegate.swift index e75f129e..75a7c6f6 100644 --- a/EduVPN/AppDelegate.swift +++ b/EduVPN/AppDelegate.swift @@ -76,8 +76,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { shouldUseColorIcons: UserDefaults.standard.isStatusItemInColor) setShowInDockEnabled(UserDefaults.standard.showInDock) - if LaunchAtLoginHelper.isOpenedOrReopenedByLoginItemHelper() && - UserDefaults.standard.showInStatusBar { + let isLauncedAtLogin = LaunchAtLoginHelper.isOpenedOrReopenedByLoginItemHelper() + + if isLauncedAtLogin && UserDefaults.standard.shouldReconnectOnLaunchAtLogin { + mainViewController?.reconnectLastUsedConnectionWhenPossible() + } + UserDefaults.standard.shouldReconnectOnLaunchAtLogin = false + + if isLauncedAtLogin && UserDefaults.standard.showInStatusBar { // If we're showing a status item and the app was launched because // the user logged in, don't show the window window.close() @@ -226,6 +232,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } if isQuittingForLogoutShutdownOrRestart() { + UserDefaults.standard.shouldReconnectOnLaunchAtLogin = true return silentlyStopVPNAndQuit(connectionService: connectionService) } else { return showAlertConfirmingStopVPNAndQuit(connectionService: connectionService) diff --git a/EduVPN/Controllers/ConnectionViewController.swift b/EduVPN/Controllers/ConnectionViewController.swift index b86f614f..194a5e73 100644 --- a/EduVPN/Controllers/ConnectionViewController.swift +++ b/EduVPN/Controllers/ConnectionViewController.swift @@ -51,11 +51,19 @@ final class ConnectionViewController: ViewController, ParametrizedViewController let connectableInstance: ConnectableInstance let serverDisplayInfo: ServerDisplayInfo let authURLTemplate: String? - let initialConnectionFlowContinuationPolicy: ConnectionViewModel.FlowContinuationPolicy + let postLoadAction: PostLoadAction + } + + enum PostLoadAction { + // What should happen after this VC is initialized or loaded + + // Begin the connection flow after the view loads + case beginConnectionFlow(continuationPolicy: ConnectionViewModel.FlowContinuationPolicy) - // If restoringPreConnectionState is non-nil, then we're restoring - // the UI at app launch for an already-on VPN - let restoringPreConnectionState: ConnectionAttempt.PreConnectionState? + // A VPN is already on. Initialize the ConnectionViewModel with the appropriate state. + // This can happen when we detect that the VPN is on at app launch. + // If shouldRenewSession, initiate renew session after the view loads. + case restoreAlreadyConnectedState(preConnectionState: ConnectionAttempt.PreConnectionState, shouldRenewSession: Bool) } weak var delegate: ConnectionViewControllerDelegate? @@ -75,7 +83,6 @@ final class ConnectionViewController: ViewController, ParametrizedViewController var sessionExpiresAt: Date? { viewModel?.sessionExpiresAt } private var parameters: Parameters! - private var isRestored: Bool = false private var viewModel: ConnectionViewModel! private var dataStore: PersistenceService.DataStore! @@ -160,7 +167,12 @@ final class ConnectionViewController: ViewController, ParametrizedViewController self.parameters = parameters if let server = parameters.connectableInstance as? ServerInstance { - let serverPreConnectionState = parameters.restoringPreConnectionState?.serverState + let serverPreConnectionState: ConnectionAttempt.ServerPreConnectionState? + if case .restoreAlreadyConnectedState(let preConnectionState, _) = parameters.postLoadAction { + serverPreConnectionState = preConnectionState.serverState + } else { + serverPreConnectionState = nil + } self.viewModel = ConnectionViewModel( server: server, connectionService: parameters.environment.connectionService, @@ -171,7 +183,12 @@ final class ConnectionViewController: ViewController, ParametrizedViewController authURLTemplate: parameters.authURLTemplate, restoringPreConnectionState: serverPreConnectionState) } else if let vpnConfigInstance = parameters.connectableInstance as? VPNConfigInstance { - let vpnConfigPreConnectionState = parameters.restoringPreConnectionState?.vpnConfigState + let vpnConfigPreConnectionState: ConnectionAttempt.VPNConfigPreConnectionState? + if case .restoreAlreadyConnectedState(let preConnectionState, _) = parameters.postLoadAction { + vpnConfigPreConnectionState = preConnectionState.vpnConfigState + } else { + vpnConfigPreConnectionState = nil + } self.viewModel = ConnectionViewModel( vpnConfigInstance: vpnConfigInstance, connectionService: parameters.environment.connectionService, @@ -182,11 +199,15 @@ final class ConnectionViewController: ViewController, ParametrizedViewController fatalError("Unknown connectable instance: \(parameters.connectableInstance)") } - self.isRestored = (parameters.restoringPreConnectionState != nil) self.dataStore = PersistenceService.DataStore(path: parameters.connectableInstance.localStoragePath) if let server = parameters.connectableInstance as? ServerInstance { - let serverPreConnectionState = parameters.restoringPreConnectionState?.serverState + let serverPreConnectionState: ConnectionAttempt.ServerPreConnectionState? + if case .restoreAlreadyConnectedState(let preConnectionState, _) = parameters.postLoadAction { + serverPreConnectionState = preConnectionState.serverState + } else { + serverPreConnectionState = nil + } if let serverPreConnectionState = serverPreConnectionState { self.profiles = serverPreConnectionState.profiles self.selectedProfileId = serverPreConnectionState.selectedProfileId @@ -203,9 +224,16 @@ final class ConnectionViewController: ViewController, ParametrizedViewController // to receive updates from the view model viewModel.delegate = self setupInitialView(viewModel: viewModel) - if !isRestored { - beginConnectionFlow(continuationPolicy: parameters.initialConnectionFlowContinuationPolicy) + + switch parameters.postLoadAction { + case .beginConnectionFlow(let continuationPolicy): + beginConnectionFlow(continuationPolicy: continuationPolicy) + case .restoreAlreadyConnectedState(_, let shouldRenewSession): + if shouldRenewSession { + renewSession() + } } + #if os(macOS) vpnSwitch.setAccessibilityIdentifier("Connection") #elseif os(iOS) diff --git a/EduVPN/Controllers/Mac/MainViewController+StatusItem.swift b/EduVPN/Controllers/Mac/MainViewController+StatusItem.swift index b7009432..b353d688 100644 --- a/EduVPN/Controllers/Mac/MainViewController+StatusItem.swift +++ b/EduVPN/Controllers/Mac/MainViewController+StatusItem.swift @@ -36,8 +36,7 @@ extension MainViewController: StatusItemControllerDelegate { }.map { self.pushConnectionVC( connectableInstance: connectableInstance, - preConnectionState: nil, - continuationPolicy: .continueWithAnyProfile) + postLoadAction: .beginConnectionFlow(continuationPolicy: .continueWithAnyProfile)) }.cauterize() } } diff --git a/EduVPN/Controllers/MainViewController.swift b/EduVPN/Controllers/MainViewController.swift index 0613d071..1523b105 100644 --- a/EduVPN/Controllers/MainViewController.swift +++ b/EduVPN/Controllers/MainViewController.swift @@ -69,6 +69,8 @@ class MainViewController: ViewController { private var isConnectionServiceInitialized = false // swiftlint:disable:next identifier_name private var shouldRenewSessionWhenConnectionServiceInitialized = false + // swiftlint:disable:next identifier_name + private var shouldReconnectWhenConnectionServiceInitialized = false #if os(macOS) var shouldPerformActionOnSelection = true @@ -132,29 +134,58 @@ class MainViewController: ViewController { } func pushConnectionVC(connectableInstance: ConnectableInstance, - preConnectionState: ConnectionAttempt.PreConnectionState?, - continuationPolicy: ConnectionViewModel.FlowContinuationPolicy, - shouldRenewSessionOnRestoration: Bool = false) { + postLoadAction: ConnectionViewController.PostLoadAction) { if let currentConnectionVC = currentConnectionVC, - currentConnectionVC.connectableInstance.isEqual(to: connectableInstance), - preConnectionState == nil { - currentConnectionVC.beginConnectionFlow(continuationPolicy: continuationPolicy) + currentConnectionVC.connectableInstance.isEqual(to: connectableInstance) { + if case .beginConnectionFlow(let continuationPolicy) = postLoadAction { + currentConnectionVC.beginConnectionFlow(continuationPolicy: continuationPolicy) + } } else { let serverDisplayInfo = viewModel.serverDisplayInfo(for: connectableInstance) let authURLTemplate = viewModel.authURLTemplate(for: connectableInstance) let connectionVC = environment.instantiateConnectionViewController( connectableInstance: connectableInstance, serverDisplayInfo: serverDisplayInfo, - initialConnectionFlowContinuationPolicy: continuationPolicy, authURLTemplate: authURLTemplate, - restoringPreConnectionState: preConnectionState) + postLoadAction: postLoadAction) connectionVC.delegate = self environment.navigationController?.popToRoot() environment.navigationController?.pushViewController(connectionVC, animated: true) - if preConnectionState != nil && shouldRenewSessionOnRestoration { - connectionVC.renewSession() + } + } + + func reconnectLastUsedConnectionWhenPossible() { + if isConnectionServiceInitialized { + if reconnectLastUsedConnection() { + #if os(macOS) + (NSApp.delegate as? AppDelegate)?.showMainWindow(self) + #endif + } + } else { + shouldReconnectWhenConnectionServiceInitialized = true + } + } + + @discardableResult + private func reconnectLastUsedConnection() -> Bool { + if let lastConnectionAttempt = environment.persistenceService.loadLastConnectionAttempt() { + let connectableInstance = lastConnectionAttempt.connectableInstance + let preConnectionState = lastConnectionAttempt.preConnectionState + if (connectableInstance is ServerInstance && preConnectionState.serverState != nil) || + (connectableInstance is VPNConfigInstance && preConnectionState.vpnConfigState != nil) { + if let lastConnectedProfileId = lastConnectionAttempt.preConnectionState.serverState?.selectedProfileId { + pushConnectionVC( + connectableInstance: connectableInstance, + postLoadAction: .beginConnectionFlow(continuationPolicy: .continueWithProfile(profileId: lastConnectedProfileId))) + } else { + pushConnectionVC( + connectableInstance: connectableInstance, + postLoadAction: .beginConnectionFlow(continuationPolicy: .doNotContinue)) + } + return true } } + return false } func scheduleSessionExpiryNotificationOnActiveVPN() -> Guarantee { @@ -280,23 +311,30 @@ extension MainViewController: ConnectionServiceInitializationDelegate { let connectableInstance = lastConnectionAttempt.connectableInstance let preConnectionState = lastConnectionAttempt.preConnectionState - if connectableInstance is ServerInstance { - precondition(lastConnectionAttempt.preConnectionState.serverState != nil) - } else if connectableInstance is VPNConfigInstance { - precondition(lastConnectionAttempt.preConnectionState.vpnConfigState != nil) + + let postLoadAction: ConnectionViewController.PostLoadAction + if (connectableInstance is ServerInstance && preConnectionState.serverState != nil) || + (connectableInstance is VPNConfigInstance && preConnectionState.vpnConfigState != nil) { + postLoadAction = .restoreAlreadyConnectedState( + preConnectionState: preConnectionState, + shouldRenewSession: shouldRenewSessionWhenConnectionServiceInitialized) + pushConnectionVC(connectableInstance: connectableInstance, postLoadAction: postLoadAction) } else { os_log("VPN is enabled at launch, but unable to identify the server from the info in last_connection_attempt.json. Disabling VPN.", log: Log.general, type: .debug) environment.connectionService.disableVPN() .cauterize() } - pushConnectionVC( - connectableInstance: connectableInstance, - preConnectionState: preConnectionState, - continuationPolicy: .doNotContinue, - shouldRenewSessionOnRestoration: shouldRenewSessionWhenConnectionServiceInitialized) case .vpnDisabled: + if shouldReconnectWhenConnectionServiceInitialized { + if reconnectLastUsedConnection() { + #if os(macOS) + (NSApp.delegate as? AppDelegate)?.showMainWindow(self) + #endif + return + } + } environment.persistenceService.removeLastConnectionAttempt() environment.notificationService.descheduleSessionExpiryNotifications() } @@ -394,9 +432,9 @@ extension MainViewController { let row = viewModel.row(at: index) if let connectableInstance = row.connectableInstance { - pushConnectionVC(connectableInstance: connectableInstance, - preConnectionState: nil, - continuationPolicy: .continueWithSingleOrLastUsedProfile) + pushConnectionVC( + connectableInstance: connectableInstance, + postLoadAction: .beginConnectionFlow(continuationPolicy: .continueWithSingleOrLastUsedProfile)) } } diff --git a/EduVPN/Helpers/UserDefaults+Preferences.swift b/EduVPN/Helpers/UserDefaults+Preferences.swift index 53f93bef..d2090152 100644 --- a/EduVPN/Helpers/UserDefaults+Preferences.swift +++ b/EduVPN/Helpers/UserDefaults+Preferences.swift @@ -17,6 +17,7 @@ extension UserDefaults { private static let isStatusItemInColorKey = "isStatusItemInColor" private static let showInDockKey = "showInDock" private static let launchAtLoginKey = "launchAtLogin" + private static let shouldReconnectOnLaunchAtLoginKey = "shouldReconnectOnLaunchAtLogin" #endif func clearPreferences() { @@ -111,6 +112,15 @@ extension UserDefaults { } } + var shouldReconnectOnLaunchAtLogin: Bool { + get { + return bool(forKey: Self.shouldReconnectOnLaunchAtLoginKey) + } + set { + set(newValue, forKey: Self.shouldReconnectOnLaunchAtLoginKey) + } + } + func registerAppDefaults() { register(defaults: [ Self.showInStatusBarKey: true, diff --git a/EduVPN/Services/Environment.swift b/EduVPN/Services/Environment.swift index 0468cf26..e8199698 100644 --- a/EduVPN/Services/Environment.swift +++ b/EduVPN/Services/Environment.swift @@ -70,14 +70,12 @@ class Environment { func instantiateConnectionViewController( connectableInstance: ConnectableInstance, serverDisplayInfo: ServerDisplayInfo, - initialConnectionFlowContinuationPolicy: ConnectionViewModel.FlowContinuationPolicy, authURLTemplate: String? = nil, - restoringPreConnectionState: ConnectionAttempt.PreConnectionState? = nil) -> ConnectionViewController { + postLoadAction: ConnectionViewController.PostLoadAction) -> ConnectionViewController { let parameters = ConnectionViewController.Parameters( environment: self, connectableInstance: connectableInstance, serverDisplayInfo: serverDisplayInfo, authURLTemplate: authURLTemplate, - initialConnectionFlowContinuationPolicy: initialConnectionFlowContinuationPolicy, - restoringPreConnectionState: restoringPreConnectionState) + postLoadAction: postLoadAction) return instantiate(ConnectionViewController.self, identifier: "Connection", parameters: parameters) } diff --git a/EduVPN/ViewModels/ConnectionViewModel.swift b/EduVPN/ViewModels/ConnectionViewModel.swift index 61428e35..72d28536 100644 --- a/EduVPN/ViewModels/ConnectionViewModel.swift +++ b/EduVPN/ViewModels/ConnectionViewModel.swift @@ -115,9 +115,9 @@ class ConnectionViewModel { // swiftlint:disable:this type_body_length enum FlowContinuationPolicy { // After getting the profile list, deciding whether to continue to connect or not case continueWithSingleOrLastUsedProfile + case continueWithProfile(profileId: String) case continueWithAnyProfile case doNotContinue - case notApplicable } private(set) var header: Header { @@ -366,6 +366,15 @@ class ConnectionViewModel { // swiftlint:disable:this type_body_length return self.continueServerConnectionFlow( profile: profile, from: viewController, serverInfo: serverInfo) + case .continueWithProfile(let chosenProfileId): + guard let profile = profiles.first(where: { $0.profileId == chosenProfileId }) else { + self.internalState = .idle + return Promise.value(()) + } + self.delegate?.connectionViewModel(self, willAutomaticallySelectProfileId: profile.profileId) + return self.continueServerConnectionFlow( + profile: profile, from: viewController, + serverInfo: serverInfo) case .continueWithAnyProfile: let anyProfile = profiles.first(where: { $0.profileId == lastUsedProfileId }) ?? profiles.first guard let profile = anyProfile else { @@ -376,7 +385,7 @@ class ConnectionViewModel { // swiftlint:disable:this type_body_length return self.continueServerConnectionFlow( profile: profile, from: viewController, serverInfo: serverInfo) - case .doNotContinue, .notApplicable: + case .doNotContinue: self.internalState = .idle return Promise.value(()) }