passepartout-apple/Passepartout/App/macOS/Menu/StatusMenu.swift
2021-09-03 12:07:27 +02:00

686 lines
26 KiB
Swift

//
// StatusMenu.swift
// Passepartout
//
// Created by Davide De Rosa on 8/14/19.
// 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 StoreKit
import PassepartoutCore
import TunnelKit
import Convenience
class StatusMenu: NSObject {
static let didAddProfile = Notification.Name("didAddProfile")
static let didRenameProfile = Notification.Name("didRenameProfile")
static let didRemoveProfile = Notification.Name("didRemoveProfile")
static let willDeactivateProfile = Notification.Name("willDeactivateProfile")
static let didActivateProfile = Notification.Name("didActivateProfile")
static let didUpdateProfile = Notification.Name("didUpdateProfile")
static let shared = StatusMenu()
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let service = TransientStore.shared.service
private var vpn: GracefulVPN {
return GracefulVPN(service: service)
}
// MARK: Button images
private let imageStatus = Asset.Assets.statusBarButtonImage.image
private lazy var imageStatusActive: NSImage = imageStatus.tinted(withColor: Theme.current.palette.colorOn)
private lazy var imageStatusInactive: NSImage = imageStatus.tinted(withColor: Theme.current.palette.colorPrimaryText)
private lazy var imageStatusInProgress: NSImage = imageStatus.tinted(withColor: Theme.current.palette.colorIndeterminate)
// MARK: Menu references
private let menu = NSMenu()
private let menuAllProfiles = NSMenu()
private lazy var itemSwitchProfile = NSMenuItem(title: L10n.Menu.SwitchProfile.title, action: nil, keyEquivalent: "")
private var itemsAllProfiles: [NSMenuItem] = []
private lazy var itemProfileName = NSMenuItem(title: "", action: nil, keyEquivalent: "")
private var itemsProfile: [NSMenuItem] = []
private lazy var itemPool = NSMenuItem(title: "", action: nil, keyEquivalent: "")
private lazy var itemToggleVPN = NSMenuItem(title: L10n.Service.Cells.Vpn.TurnOn.caption, action: nil, keyEquivalent: "")
private lazy var itemReconnectVPN = NSMenuItem(title: L10n.Service.Cells.Reconnect.caption, action: #selector(reconnectVPN), keyEquivalent: "")
private override init() {
super.init()
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(vpnDidUpdate), name: VPN.didChangeStatus, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func install() {
guard let button = statusItem.button else {
return
}
button.image = imageStatus
VPN.shared.prepare {
self.rebuild()
self.statusItem.menu = self.menu
self.service.delegate = self
self.reloadVpnStatus()
}
}
private func rebuild() {
menu.removeAllItems()
// main actions
let itemShow = NSMenuItem(title: L10n.Menu.Show.title, action: #selector(showOrganizer), keyEquivalent: "")
let itemPreferences = NSMenuItem(title: L10n.Menu.Preferences.title.asContinuation, action: #selector(showPreferences), keyEquivalent: ",")
itemShow.target = self
itemPreferences.target = self
menu.addItem(itemShow)
menu.addItem(itemPreferences)
menu.addItem(itemSwitchProfile)
reloadProfiles()
menu.addItem(.separator())
// active profile
menu.addItem(itemProfileName)
setActiveProfile(service.activeProfile)
menu.addItem(.separator())
// support
let menuSupport = NSMenu()
let itemCommunity = NSMenuItem(title: L10n.Organizer.Cells.JoinCommunity.caption.asContinuation, action: #selector(joinCommunity), keyEquivalent: "")
let itemDonate = NSMenuItem(title: L10n.Organizer.Cells.Donate.caption, action: nil, keyEquivalent: "")
// let itemGitHubSponsors = NSMenuItem(title: L10n.Organizer.Cells.GithubSponsors.caption.asContinuation, action: #selector(seeGitHubSponsors), keyEquivalent: "")
// let itemTranslate = NSMenuItem(title: L10n.Organizer.Cells.Translate.caption.asContinuation, action: #selector(offerToTranslate), keyEquivalent: "")
let menuDonate = NSMenu()
ProductManager.shared.listProducts { [weak self] products, error in
self?.addDonations(fromProducts: products ?? [], to: menuDonate)
}
menuSupport.setSubmenu(menuDonate, for: itemDonate)
let itemFAQ = NSMenuItem(title: L10n.About.Cells.Faq.caption.asContinuation, action: #selector(visitFAQ), keyEquivalent: "")
itemCommunity.target = self
// itemDonate.target = self
// itemGitHubSponsors.target = self
// itemTranslate.target = self
itemFAQ.target = self
menuSupport.addItem(itemDonate)
menuSupport.addItem(itemCommunity)
// menuSupport.addItem(.separator())
// menuSupport.addItem(itemGitHubSponsors)
// menuSupport.addItem(itemTranslate)
if ProductManager.shared.isEligibleForFeedback() {
let itemReview = NSMenuItem(title: L10n.Organizer.Cells.WriteReview.caption.asContinuation, action: #selector(writeReview), keyEquivalent: "")
itemReview.target = self
menuSupport.addItem(itemReview)
}
menuSupport.addItem(.separator())
menuSupport.addItem(itemFAQ)
if ProductManager.shared.isEligibleForFeedback() {
let itemReport = NSMenuItem(title: L10n.Service.Cells.ReportIssue.caption.asContinuation, action: #selector(reportConnectivityIssue), keyEquivalent: "")
itemReport.target = self
menuSupport.addItem(itemReport)
}
let itemSupport = NSMenuItem(title: L10n.Menu.Support.title, action: nil, keyEquivalent: "")
menu.setSubmenu(menuSupport, for: itemSupport)
menu.addItem(itemSupport)
// share
let menuShare = NSMenu()
let itemTweet = NSMenuItem(title: L10n.About.Cells.ShareTwitter.caption, action: #selector(shareTwitter), keyEquivalent: "")
let itemInvite = NSMenuItem(title: L10n.About.Cells.ShareGeneric.caption.asContinuation, action: #selector(shareGeneric), keyEquivalent: "")
let itemAlternativeTo = NSMenuItem(title: "AlternativeTo".asContinuation, action: #selector(visitAlternativeTo), keyEquivalent: "")
itemTweet.target = self
itemInvite.target = self
itemAlternativeTo.target = self
menuShare.addItem(itemTweet)
menuShare.addItem(itemInvite)
menuShare.addItem(itemAlternativeTo)
let itemShare = NSMenuItem(title: L10n.About.Sections.Share.header, action: nil, keyEquivalent: "")
menu.setSubmenu(menuShare, for: itemShare)
menu.addItem(itemShare)
menu.addItem(.separator())
// secondary
let itemAbout = NSMenuItem(title: L10n.Organizer.Cells.About.caption(GroupConstants.App.name), action: #selector(showAbout), keyEquivalent: "")
let itemQuit = NSMenuItem(title: L10n.Menu.Quit.title(GroupConstants.App.name), action: #selector(quit), keyEquivalent: "q")
itemAbout.target = self
itemQuit.target = self
menu.addItem(itemAbout)
menu.addItem(itemQuit)
}
private func reloadProfiles() {
for item in itemsAllProfiles {
menuAllProfiles.removeItem(item)
}
itemsAllProfiles.removeAll()
let sortedProfileKeys = service.allProfileKeys().sorted {
service.screenTitle($0).lowercased() < service.screenTitle($1).lowercased()
}
for profileKey in sortedProfileKeys {
let title = service.screenTitle(profileKey)
let item = NSMenuItem(title: title, action: #selector(switchActiveProfile(_:)), keyEquivalent: "")
item.representedObject = profileKey
item.target = self
item.state = service.isActiveProfile(profileKey) ? .on : .off
menuAllProfiles.addItem(item)
itemsAllProfiles.append(item)
}
menu.setSubmenu(menuAllProfiles, for: itemSwitchProfile)
}
func refreshWithCurrentProfile() {
setActiveProfile(service.activeProfile)
}
func setActiveProfile(_ profile: ConnectionProfile?) {
let startIndex = menu.index(of: itemProfileName)
var i = startIndex + 1
for item in itemsProfile {
menu.removeItem(item)
}
itemsProfile.removeAll()
guard let profile = profile else {
itemProfileName.title = L10n.Menu.ActiveProfile.Title.none
// itemProfileName.image = nil
statusItem.button?.image = imageStatusInactive
statusItem.button?.toolTip = nil
return
}
let profileTitle = service.screenTitle(ProfileKey(profile))
itemProfileName.title = profileTitle
// itemProfileName.image = profile.image
let needsCredentials = service.needsCredentials(for: profile)
if !needsCredentials {
itemToggleVPN.indentationLevel = 1
itemReconnectVPN.indentationLevel = 1
itemToggleVPN.target = self
itemReconnectVPN.target = self
menu.insertItem(itemToggleVPN, at: i)
i += 1
menu.insertItem(itemReconnectVPN, at: i)
i += 1
itemsProfile.append(itemToggleVPN)
itemsProfile.append(itemReconnectVPN)
} else {
let itemMissingCredentials = NSMenuItem(title: L10n.Menu.ActiveProfile.Messages.missingCredentials, action: nil, keyEquivalent: "")
itemMissingCredentials.indentationLevel = 1
menu.insertItem(itemMissingCredentials, at: i)
i += 1
itemsProfile.append(itemMissingCredentials)
}
reloadVpnStatus()
if !needsCredentials, let providerProfile = profile as? ProviderConnectionProfile {
// endpoint (port only)
let itemEndpoint = NSMenuItem(title: L10n.Endpoint.title, action: nil, keyEquivalent: "")
itemEndpoint.indentationLevel = 1
let menuEndpoint = NSMenu()
// automatic
let itemEndpointAutomatic = NSMenuItem(title: L10n.Endpoint.Cells.AnyProtocol.caption, action: #selector(connectToEndpoint(_:)), keyEquivalent: "")
itemEndpointAutomatic.target = self
if providerProfile.customProtocol == nil {
itemEndpointAutomatic.state = .on
}
menuEndpoint.addItem(itemEndpointAutomatic)
for proto in profile.protocols {
let item = NSMenuItem(title: proto.description, action: #selector(connectToEndpoint(_:)), keyEquivalent: "")
item.representedObject = proto
item.target = self
if providerProfile.customProtocol == proto {
item.state = .on
}
menuEndpoint.addItem(item)
}
menu.setSubmenu(menuEndpoint, for: itemEndpoint)
menu.insertItem(itemEndpoint, at: i)
i += 1
itemsProfile.append(itemEndpoint)
// account
let itemAccount = NSMenuItem(title: L10n.Account.title.asContinuation, action: #selector(editAccountCredentials(_:)), keyEquivalent: "")
menu.insertItem(itemAccount, at: i)
i += 1
itemAccount.target = self
itemAccount.indentationLevel = 1
itemsProfile.append(itemAccount)
// customize
let itemCustomize = NSMenuItem(title: L10n.Menu.ActiveProfile.Items.Customize.title, action: #selector(customizeProfile(_:)), keyEquivalent: "")
menu.insertItem(itemCustomize, at: i)
i += 1
itemCustomize.target = self
itemCustomize.indentationLevel = 1
itemsProfile.append(itemCustomize)
let itemSep1: NSMenuItem = .separator()
menu.insertItem(itemSep1, at: i)
i += 1
itemsProfile.append(itemSep1)
// guard poolDescription = providerProfile.pool?.localizedId else {
// fatalError("No pool selected?")
// }
itemPool.title = providerProfile.pool?.localizedId ?? ""
menu.insertItem(itemPool, at: i)
i += 1
itemsProfile.append(itemPool)
let infrastructure = providerProfile.infrastructure
for category in infrastructure.categories {
let title = category.name.isEmpty ? L10n.Global.Values.default : category.name.capitalized
let submenu = NSMenu()
let itemCategory = NSMenuItem(title: title, action: nil, keyEquivalent: "")
itemCategory.indentationLevel = 1
for group in category.groups.sorted() {
let title = group.localizedCountry
let itemGroup = NSMenuItem(title: title, action: #selector(connectToGroup(_:)), keyEquivalent: "")
itemGroup.image = group.logo
itemGroup.target = self
itemGroup.representedObject = group
let submenuGroup = NSMenu()
for pool in group.pools.sortedPools() {
let title = !pool.secondaryId.isEmpty ? pool.secondaryId : L10n.Global.Values.default
let item = NSMenuItem(title: title, action: #selector(connectToPool(_:)), keyEquivalent: "")
if let extraCountry = pool.extraCountries?.first {
item.image = extraCountry.image
}
item.target = self
item.representedObject = pool
submenuGroup.addItem(item)
if pool.id == providerProfile.poolId {
itemCategory.state = .on
itemGroup.state = .on
item.state = .on
}
}
if submenuGroup.numberOfItems > 1 {
itemGroup.action = nil
itemGroup.submenu = submenuGroup
}
submenu.addItem(itemGroup)
}
menu.setSubmenu(submenu, for: itemCategory)
menu.insertItem(itemCategory, at: i)
i += 1
itemsProfile.append(itemCategory)
}
} else {
// account
let itemAccount = NSMenuItem(title: L10n.Account.title.asContinuation, action: #selector(editAccountCredentials(_:)), keyEquivalent: "")
menu.insertItem(itemAccount, at: i)
i += 1
itemAccount.target = self
itemAccount.indentationLevel = 1
itemsProfile.append(itemAccount)
// customize
let itemCustomize = NSMenuItem(title: L10n.Menu.ActiveProfile.Items.Customize.title, action: #selector(customizeProfile(_:)), keyEquivalent: "")
menu.insertItem(itemCustomize, at: i)
i += 1
itemCustomize.target = self
itemCustomize.indentationLevel = 1
itemsProfile.append(itemCustomize)
let itemSep1: NSMenuItem = .separator()
menu.insertItem(itemSep1, at: i)
i += 1
itemsProfile.append(itemSep1)
}
}
// MARK: Actions
@objc private func showAbout() {
WindowManager.shared.showAbout()
}
@objc private func showOrganizer() {
WindowManager.shared.showOrganizer()
}
@objc private func showPreferences() {
WindowManager.shared.showPreferences()
}
@objc private func switchActiveProfile(_ sender: Any?) {
guard let item = sender as? NSMenuItem else {
return
}
guard let profileKey = item.representedObject as? ProfileKey, let profile = service.profile(withKey: profileKey) else {
return
}
let wasConnected = (vpn.status == .connected) || (vpn.status == .connecting)
// XXX: copy/paste from ServiceViewController.activateProfile()
service.activateProfile(profile)
vpn.disconnect(completionHandler: nil)
if wasConnected {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { [weak self] in
self?.vpn.reconnect(completionHandler: nil)
}
}
}
@objc private func enableVPN() {
vpn.reconnect { _ in
self.reloadVpnStatus()
}
}
@objc private func disableVPN() {
vpn.disconnect { _ in
self.reloadVpnStatus()
}
}
@objc private func reconnectVPN() {
vpn.reconnect(completionHandler: nil)
}
@objc private func connectToGroup(_ sender: Any?) {
guard let item = sender as? NSMenuItem else {
return
}
guard let group = item.representedObject as? PoolGroup else {
return
}
guard let profile = service.activeProfile as? ProviderConnectionProfile else {
return
}
assert(!group.pools.isEmpty)
profile.poolId = group.pools.randomElement()!.id
vpn.reconnect(completionHandler: nil)
// update menu
setActiveProfile(profile)
}
@objc private func connectToPool(_ sender: Any?) {
guard let item = sender as? NSMenuItem else {
return
}
guard let pool = item.representedObject as? Pool else {
return
}
guard let profile = service.activeProfile as? ProviderConnectionProfile else {
return
}
profile.poolId = pool.id
vpn.reconnect(completionHandler: nil)
// update menu
setActiveProfile(profile)
}
@objc private func connectToEndpoint(_ sender: Any?) {
guard let item = sender as? NSMenuItem else {
return
}
guard let profile = service.activeProfile as? ProviderConnectionProfile else {
return
}
profile.customProtocol = item.representedObject as? EndpointProtocol
vpn.reconnect(completionHandler: nil)
// update menu
setActiveProfile(profile)
}
@objc private func editAccountCredentials(_ sender: Any?) {
let organizer = WindowManager.shared.showOrganizer()
guard organizer?.contentViewController?.presentedViewControllers?.isEmpty ?? true else {
return
}
let accountController = StoryboardScene.Service.accountViewController.instantiate()
accountController.profile = service.activeProfile
organizer?.contentViewController?.presentAsSheet(accountController)
}
@objc private func customizeProfile(_ sender: Any?) {
let organizer = WindowManager.shared.showOrganizer()
guard organizer?.contentViewController?.presentedViewControllers?.isEmpty ?? true else {
return
}
let profileCustomization = StoryboardScene.Service.profileCustomizationContainerViewController.instantiate()
profileCustomization.profile = service.activeProfile
organizer?.contentViewController?.presentAsSheet(profileCustomization)
}
@objc private func joinCommunity() {
NSWorkspace.shared.open(AppConstants.URLs.subreddit)
}
@objc private func writeReview() {
let url = Reviewer.urlForReview(withAppId: AppConstants.App.appStoreId)
NSWorkspace.shared.open(url)
}
@objc private func showDonations() {
NSWorkspace.shared.open(AppConstants.URLs.donate)
}
@objc private func seeGitHubSponsors() {
NSWorkspace.shared.open(AppConstants.URLs.githubSponsors)
}
@objc private func offerToTranslate() {
let V = AppConstants.Translations.Email.self
let recipient = V.recipient
let subject = V.subject
let body = V.body(V.template)
guard let url = URL.mailto(to: recipient, subject: subject, body: body) else {
return
}
NSWorkspace.shared.open(url)
}
@objc private func visitFAQ() {
NSWorkspace.shared.open(AppConstants.URLs.faq)
}
@objc private func reportConnectivityIssue() {
let issue = Issue(debugLog: true, profile: TransientStore.shared.service.activeProfile)
IssueReporter.shared.present(withIssue: issue)
}
@objc private func shareTwitter() {
NSWorkspace.shared.open(AppConstants.URLs.twitterIntent(withMessage: L10n.Share.message))
}
@objc private func shareGeneric() {
guard let source = statusItem.button else {
return
}
let message = "\(L10n.Share.message) \(AppConstants.URLs.website)"
let picker = NSSharingServicePicker(items: [message])
picker.show(relativeTo: source.bounds, of: source, preferredEdge: .minY)
}
@objc private func visitAlternativeTo() {
NSWorkspace.shared.open(AppConstants.URLs.alternativeTo)
}
@objc private func quit() {
NSApp.terminate(self)
}
// MARK: Notifications
@objc private func vpnDidUpdate() {
reloadVpnStatus()
}
// MARK: Helpers
private func addDonations(fromProducts products: [SKProduct], to menu: NSMenu) {
products.sorted { $0.price.decimalValue < $1.price.decimalValue }.forEach {
guard let p = Product(rawValue: $0.productIdentifier), p.isDonation, let price = $0.localizedPrice else {
return
}
let title = "\($0.localizedTitle) (\(price))"
let item = NSMenuItem(title: title, action: #selector(performDonation(_:)), keyEquivalent: "")
item.target = self
item.representedObject = $0
menu.addItem(item)
}
}
@objc private func performDonation(_ item: NSMenuItem) {
guard let product = item.representedObject as? SKProduct else {
return
}
ProductManager.shared.purchase(product) { _, _ in
}
}
private func reloadVpnStatus() {
if vpn.isEnabled {
itemToggleVPN.title = L10n.Service.Cells.Vpn.TurnOff.caption
itemToggleVPN.action = #selector(disableVPN)
} else {
itemToggleVPN.title = L10n.Service.Cells.Vpn.TurnOn.caption
itemToggleVPN.action = #selector(enableVPN)
}
if let profile = service.activeProfile {
let profileTitle = service.screenTitle(ProfileKey(profile))
statusItem.button?.toolTip = "\(GroupConstants.App.name)\n\(profileTitle)\n\((vpn.status ?? .disconnected).uiDescription)"
} else {
statusItem.button?.toolTip = nil
}
switch vpn.status ?? .disconnected {
case .connected:
statusItem.button?.image = imageStatusActive
statusItem.button?.alphaValue = 1.0
Reviewer.shared.reportEvent()
case .connecting:
statusItem.button?.image = imageStatusInProgress
statusItem.button?.alphaValue = 1.0
case .disconnected:
statusItem.button?.image = imageStatusInactive
statusItem.button?.alphaValue = 0.5
case .disconnecting:
statusItem.button?.image = imageStatusInProgress
statusItem.button?.alphaValue = 1.0
}
}
}
extension StatusMenu: ConnectionServiceDelegate {
func connectionService(didAdd profile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // add
reloadProfiles()
NotificationCenter.default.post(name: StatusMenu.didAddProfile, object: profile)
}
func connectionService(didRename profile: ConnectionProfile, to newTitle: String) {
TransientStore.shared.serialize(withProfiles: false) // rename
reloadProfiles()
NotificationCenter.default.post(name: StatusMenu.didRenameProfile, object: profile)
}
func connectionService(didRemoveProfileWithKey key: ProfileKey) {
TransientStore.shared.serialize(withProfiles: false) // delete
reloadProfiles()
NotificationCenter.default.post(name: StatusMenu.didRemoveProfile, object: key)
}
func connectionService(willDeactivate profile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // deactivate
reloadProfiles()
setActiveProfile(nil)
NotificationCenter.default.post(name: StatusMenu.willDeactivateProfile, object: profile)
}
func connectionService(didActivate profile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // activate
reloadProfiles()
setActiveProfile(profile)
NotificationCenter.default.post(name: StatusMenu.didActivateProfile, object: profile)
}
func connectionService(didUpdate profile: ConnectionProfile) {
guard let providerProfile = profile as? ProviderConnectionProfile else {
return
}
itemPool.title = providerProfile.pool?.localizedId ?? ""
NotificationCenter.default.post(name: StatusMenu.didUpdateProfile, object: profile)
}
}