passepartout-apple/Passepartout/App/macOS/Scenes/OrganizerViewController.swift

394 lines
15 KiB
Swift

//
// OrganizerViewController.swift
// Passepartout
//
// Created by Davide De Rosa on 6/6/18.
// Copyright (c) 2021 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout 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.
//
// Passepartout 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 Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Cocoa
import SwiftyBeaver
import PassepartoutConstants
import PassepartoutCore
import TunnelKitManager
private let log = SwiftyBeaver.self
class OrganizerViewController: NSViewController {
@IBOutlet private weak var viewProfiles: NSView!
private lazy var tableProfiles: OrganizerProfileTableView = .get()
@IBOutlet private weak var buttonRemoveConfiguration: NSButton!
@IBOutlet private weak var serviceController: ServiceViewController?
private let service = TransientStore.shared.service
private var profiles: [ConnectionProfile] = []
private var importer: HostImporter?
private var profilePendingRemoval: ConnectionProfile?
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
viewProfiles.addSubview(tableProfiles)
tableProfiles.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableProfiles.topAnchor.constraint(equalTo: viewProfiles.topAnchor),
tableProfiles.bottomAnchor.constraint(equalTo: viewProfiles.bottomAnchor),
tableProfiles.leftAnchor.constraint(equalTo: viewProfiles.leftAnchor),
tableProfiles.rightAnchor.constraint(equalTo: viewProfiles.rightAnchor),
])
buttonRemoveConfiguration.title = L10n.Organizer.Cells.Uninstall.caption
tableProfiles.selectionBlock = { [weak self] in
self?.serviceController?.setProfile($0)
}
tableProfiles.deselectionBlock = { [weak self] in
self?.serviceController?.setProfile(nil)
}
tableProfiles.delegate = self
reloadProfiles()
tableProfiles.reloadData()
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(menuDidAddProfile(_:)), name: StatusMenu.didAddProfile, object: nil)
nc.addObserver(self, selector: #selector(menuDidRenameProfile(_:)), name: StatusMenu.didRenameProfile, object: nil)
nc.addObserver(self, selector: #selector(menuDidRemoveProfile(_:)), name: StatusMenu.didRemoveProfile, object: nil)
nc.addObserver(self, selector: #selector(menuDidActivateProfile(_:)), name: StatusMenu.didActivateProfile, object: nil)
}
// MARK: Actions
@objc private func addProvider(_ sender: Any?) {
guard let item = sender as? NSMenuItem, let metadata = item.representedObject as? Infrastructure.Metadata else {
return
}
do {
try ProductManager.shared.verifyEligible(forProvider: metadata)
} catch {
presentPurchaseScreen(forProduct: metadata.product)
return
}
// make sure that infrastructure exists locally
guard let _ = InfrastructureFactory.shared.infrastructure(forName: metadata.name) else {
_ = InfrastructureFactory.shared.update(metadata.name, notBeforeInterval: nil) { [weak self] in
guard let _ = $0 else {
self?.alertMissingInfrastructure(forMetadata: metadata, error: $1)
return
}
self?.confirmAddProvider(withMetadata: metadata)
}
return
}
confirmAddProvider(withMetadata: metadata)
}
private func alertMissingInfrastructure(forMetadata metadata: Infrastructure.Metadata, error: Error?) {
var message = L10n.Wizards.Provider.Alerts.Unavailable.message
if let error = error {
log.error("Unable to download missing \(metadata.description) infrastructure (network error): \(error.localizedDescription)")
message.append(" \(error.localizedDescription)")
} else {
log.error("Unable to download missing \(metadata.description) infrastructure (API error)")
}
let alert = Macros.warning(metadata.description, message)
_ = alert.presentModally(withOK: L10n.Global.ok, cancel: nil)
}
private func confirmAddProvider(withMetadata metadata: Infrastructure.Metadata) {
perform(segue: StoryboardSegue.Main.enterAccountSegueIdentifier, sender: metadata.name)
}
@objc private func addHost() {
let panel = NSOpenPanel()
panel.title = L10n.Organizer.Alerts.OpenHostFile.title
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.canCreateDirectories = false
panel.allowedFileTypes = ["ovpn"]
guard panel.runModal() == .OK, let url = panel.url else {
return
}
importer = HostImporter(withConfigurationURL: url)
importer?.importHost(withPassphrase: nil)
}
@objc private func updateProvidersList() {
InfrastructureFactory.shared.updateIndex {
if let error = $0 {
log.error("Unable to update providers list: \(error)")
return
}
// ProductManager.shared.listProducts { (products, error) in
// if let error = error {
// log.error("Unable to list products: \(error)")
// return
// }
// }
}
}
private func confirmRenameProfile(_ profile: ConnectionProfile, to newTitle: String) {
// rename to existing title -> confirm overwrite existing
if let existingProfile = service.hostProfile(withTitle: newTitle) {
let alert = Macros.warning(
L10n.Service.Alerts.Rename.title,
L10n.Wizards.Host.Alerts.Existing.message
)
alert.present(in: view.window, withOK: L10n.Global.ok, cancel: L10n.Global.cancel, handler: {
self.doReplaceProfile(profile, to: newTitle, existingProfile: existingProfile)
}, cancelHandler: nil)
return
}
// do nothing if same title
if newTitle != service.screenTitle(forHostId: profile.id) {
service.renameProfile(profile, to: newTitle)
}
}
private func doReplaceProfile(_ profile: ConnectionProfile, to newTitle: String, existingProfile: ConnectionProfile) {
let wasActive = service.isActiveProfile(existingProfile)
service.removeProfile(ProfileKey(existingProfile))
service.renameProfile(profile, to: newTitle)
if wasActive {
service.activateProfile(profile)
}
serviceController?.setProfile(profile)
}
@IBAction private func confirmVpnProfileDeletion(_ sender: Any?) {
let alert = Macros.warning(
L10n.Organizer.Cells.Uninstall.caption,
L10n.Organizer.Alerts.DeleteVpnProfile.message
)
alert.present(in: view.window, withOK: L10n.Global.ok, cancel: L10n.Global.cancel, handler: {
VPN.shared.uninstall(completionHandler: nil)
}, cancelHandler: nil)
}
override func prepare(for segue: NSStoryboardSegue, sender: Any?) {
if let vc = segue.destinationController as? ServiceViewController {
serviceController = vc
} else if let vc = segue.destinationController as? AccountViewController {
// add provider -> account
if let name = sender as? InfrastructureName {
vc.profile = ProviderConnectionProfile(name: name)
}
// add host -> rename -> account
else {
vc.profile = sender as? ConnectionProfile
}
vc.delegate = self
} else if let vc = segue.destinationController as? TextInputViewController {
guard let profile = sender as? ConnectionProfile else {
return
}
// rename host
vc.caption = L10n.Service.Alerts.Rename.title.asCaption
vc.text = service.screenTitle(forHostId: profile.id)
vc.placeholder = L10n.Global.Host.TitleInput.placeholder
vc.object = profile
vc.delegate = self
}
}
// MARK: Notifications
@objc private func menuDidAddProfile(_ notification: Notification) {
reloadProfiles()
tableProfiles.reloadData()
}
@objc private func menuDidRenameProfile(_ notification: Notification) {
reloadProfiles()
tableProfiles.reloadData()
}
@objc private func menuDidRemoveProfile(_ notification: Notification) {
reloadProfiles()
tableProfiles.selectedRow = nil
tableProfiles.reloadData()
}
@objc private func menuDidActivateProfile(_ notification: Notification) {
guard let profile = notification.object as? ConnectionProfile else {
return
}
for (i, p) in profiles.enumerated() {
if p.id == profile.id {
tableProfiles.selectedRow = i
break
}
}
tableProfiles.reloadData()
}
// MARK: Helpers
private func removePendingProfile() {
guard let profile = profilePendingRemoval else {
return
}
service.removeProfile(ProfileKey(profile))
profilePendingRemoval = nil
if profiles.isEmpty || !service.hasActiveProfile() {
serviceController?.setProfile(nil)
VPN.shared.uninstall(completionHandler: nil)
}
}
private func reloadProfiles() {
let providerIds = service.ids(forContext: .provider)
let hostIds = service.ids(forContext: .host)
profiles.removeAll()
for id in providerIds {
guard let profile = service.profile(withContext: .provider, id: id) else {
continue
}
profiles.append(profile)
}
for id in hostIds {
guard let profile = service.profile(withContext: .host, id: id) else {
continue
}
profiles.append(profile)
}
profiles.sort {
service.screenTitle(ProfileKey($0)).lowercased() < service.screenTitle(ProfileKey($1)).lowercased()
}
tableProfiles.rows = profiles
for (i, p) in profiles.enumerated() {
if service.isActiveProfile(p) {
tableProfiles.selectedRow = i
break
}
}
}
}
extension OrganizerViewController: OrganizerProfileTableViewDelegate {
func profileTableViewDidRequestAdd(_ profileTableView: OrganizerProfileTableView, sender: NSView) {
guard let event = NSApp.currentEvent else {
return
}
let menu = NSMenu()
let itemProvider = NSMenuItem(title: L10n.Organizer.Menus.provider, action: nil, keyEquivalent: "")
let menuProvider = NSMenu()
let availableMetadata = service.availableProviders()
if !availableMetadata.isEmpty {
for metadata in availableMetadata {
let item = NSMenuItem(title: metadata.description, action: #selector(addProvider(_:)), keyEquivalent: "")
// item.image = metadata.logo
item.representedObject = metadata
menuProvider.addItem(item)
}
} else {
let item = NSMenuItem(title: L10n.Organizer.Menus.Provider.unavailable, action: nil, keyEquivalent: "")
item.isEnabled = false
menuProvider.addItem(item)
}
menuProvider.addItem(.separator())
let itemProviderUpdateList = NSMenuItem(title: L10n.Wizards.Provider.Cells.UpdateList.caption, action: #selector(updateProvidersList), keyEquivalent: "")
menuProvider.addItem(itemProviderUpdateList)
menu.setSubmenu(menuProvider, for: itemProvider)
menu.addItem(itemProvider)
let menuHost = NSMenuItem(title: L10n.Organizer.Menus.host.asContinuation, action: #selector(addHost), keyEquivalent: "")
menu.addItem(menuHost)
NSMenu.popUpContextMenu(menu, with: event, for: sender)
}
func profileTableView(_ profileTableView: OrganizerProfileTableView, didRequestRemove profile: ConnectionProfile) {
profilePendingRemoval = profile
let alert = Macros.warning(
L10n.Organizer.Alerts.RemoveProfile.title,
L10n.Organizer.Alerts.RemoveProfile.message(service.screenTitle(ProfileKey(profile)))
)
alert.present(in: view.window, withOK: L10n.Global.ok, cancel: L10n.Global.cancel, handler: {
self.removePendingProfile()
}, cancelHandler: nil)
}
func profileTableView(_ profileTableView: OrganizerProfileTableView, didRequestRename profile: HostConnectionProfile) {
perform(segue: StoryboardSegue.Main.renameProfileSegueIdentifier, sender: profile)
}
}
extension OrganizerViewController: AccountViewControllerDelegate {
func accountController(_ accountController: AccountViewController, shouldUpdateCredentials credentials: Credentials, forProfile profile: ConnectionProfile) -> Bool {
guard profile.requiresCredentials else {
return true
}
return credentials.isValid
}
func accountController(_ accountController: AccountViewController, didUpdateCredentials credentials: Credentials, forProfile profile: ConnectionProfile) {
// finish adding provider (host adding is done by HostImporter)
if profile.context == .provider {
service.addOrReplaceProfile(profile, credentials: credentials)
}
}
func accountControllerDidCancel(_ accountController: AccountViewController) {
}
}
// rename existing host profile
extension OrganizerViewController: TextInputViewControllerDelegate {
func textInputController(_ textInputController: TextInputViewController, shouldEnterText text: String) -> Bool {
return true//text.rangeOfCharacter(from: CharacterSet.filename.inverted) == nil
}
func textInputController(_ textInputController: TextInputViewController, didEnterText text: String) {
guard let profile = textInputController.object as? ConnectionProfile else {
return
}
confirmRenameProfile(profile, to: text)
dismiss(textInputController)
}
}