diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt index fb8aca0b3..8100fb855 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt @@ -4,6 +4,7 @@ package dev.firezone.android.core.data import android.content.Context import android.content.SharedPreferences import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import dev.firezone.android.BuildConfig import dev.firezone.android.core.data.model.Config @@ -14,6 +15,40 @@ import kotlinx.coroutines.flow.flowOn import java.security.MessageDigest import javax.inject.Inject +const val ON_SYMBOL: String = "<->" +const val OFF_SYMBOL: String = " — " + +enum class ResourceState { + @SerializedName("enabled") + ENABLED, + + @SerializedName("disabled") + DISABLED, + + @SerializedName("unset") + UNSET, +} + +fun ResourceState.isEnabled(): Boolean { + return this == ResourceState.ENABLED +} + +fun ResourceState.stateSymbol(): String { + return if (this.isEnabled()) { + ON_SYMBOL + } else { + OFF_SYMBOL + } +} + +fun ResourceState.toggle(): ResourceState { + return if (this.isEnabled()) { + ResourceState.DISABLED + } else { + ResourceState.ENABLED + } +} + internal class Repository @Inject constructor( @@ -94,14 +129,14 @@ internal class Repository .putString(DEVICE_ID_KEY, value) .apply() - fun getDisabledResourcesSync(): Set { - val jsonString = sharedPreferences.getString(DISABLED_RESOURCES_KEY, null) ?: return hashSetOf() - val type = object : TypeToken>() {}.type + fun getInternetResourceStateSync(): ResourceState { + val jsonString = sharedPreferences.getString(ENABLED_INTERNET_RESOURCE_KEY, null) ?: return ResourceState.UNSET + val type = object : TypeToken() {}.type return Gson().fromJson(jsonString, type) } - fun saveDisabledResourcesSync(value: Set): Unit = - sharedPreferences.edit().putString(DISABLED_RESOURCES_KEY, Gson().toJson(value)) + fun saveInternetResourceStateSync(value: ResourceState): Unit = + sharedPreferences.edit().putString(ENABLED_INTERNET_RESOURCE_KEY, Gson().toJson(value)) .apply() fun saveNonce(value: String): Flow = @@ -183,6 +218,6 @@ internal class Repository private const val NONCE_KEY = "nonce" private const val STATE_KEY = "state" private const val DEVICE_ID_KEY = "deviceId" - private const val DISABLED_RESOURCES_KEY = "disabledResources" + private const val ENABLED_INTERNET_RESOURCE_KEY = "enabledInternetResource" } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt index 74182a31d..9ed4c1de9 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt @@ -21,9 +21,14 @@ import androidx.fragment.app.activityViewModels import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.button.MaterialButton import dev.firezone.android.R +import dev.firezone.android.core.data.ResourceState +import dev.firezone.android.core.data.isEnabled import dev.firezone.android.tunnel.model.StatusEnum -class ResourceDetailsBottomSheet(private val resource: ViewResource, private val activity: SessionActivity) : BottomSheetDialogFragment() { +class ResourceDetailsBottomSheet( + private val resource: ResourceViewModel, + private val internetResourceToggle: () -> ResourceState, +) : BottomSheetDialogFragment() { private lateinit var view: View private val viewModel: SessionViewModel by activityViewModels() @@ -51,8 +56,6 @@ class ResourceDetailsBottomSheet(private val resource: ViewResource, private val resourceHeader() - refreshDisableToggleButton() - if (!resource.sites.isNullOrEmpty()) { val site = resource.sites.first() siteNameTextView.text = site.name @@ -89,11 +92,11 @@ class ResourceDetailsBottomSheet(private val resource: ViewResource, private val } } - private fun resourceToggleText(): String { - if (resource.enabled) { - return "Disable this resource" + private fun resourceToggleText(resource: ResourceViewModel): String { + return if (resource.state.isEnabled()) { + "Disable this resource" } else { - return "Enable this resource" + "Enable this resource" } } @@ -122,6 +125,8 @@ class ResourceDetailsBottomSheet(private val resource: ViewResource, private val resourceDescriptionLayout.visibility = View.VISIBLE resourceAddressDescriptionTextView.text = "All network traffic" + + refreshDisableToggleButton() } private fun nonInternetResourceHeader() { @@ -173,17 +178,12 @@ class ResourceDetailsBottomSheet(private val resource: ViewResource, private val } private fun refreshDisableToggleButton() { - if (resource.canBeDisabled) { - val toggleResourceEnabled: MaterialButton = view.findViewById(R.id.toggleResourceEnabled) - toggleResourceEnabled.visibility = View.VISIBLE - toggleResourceEnabled.text = resourceToggleText() - toggleResourceEnabled.setOnClickListener { - resource.enabled = !resource.enabled - - activity.onViewResourceToggled(resource) - - refreshDisableToggleButton() - } + val toggleResourceEnabled: MaterialButton = view.findViewById(R.id.toggleResourceEnabled) + toggleResourceEnabled.visibility = View.VISIBLE + toggleResourceEnabled.text = resourceToggleText(resource) + toggleResourceEnabled.setOnClickListener { + resource.state = internetResourceToggle() + refreshDisableToggleButton() } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceViewModel.kt new file mode 100644 index 000000000..47b5ab13a --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceViewModel.kt @@ -0,0 +1,44 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.features.session.ui + +import dev.firezone.android.core.data.ResourceState +import dev.firezone.android.core.data.stateSymbol +import dev.firezone.android.tunnel.model.Resource +import dev.firezone.android.tunnel.model.ResourceType +import dev.firezone.android.tunnel.model.Site +import dev.firezone.android.tunnel.model.StatusEnum +import dev.firezone.android.tunnel.model.isInternetResource + +class ResourceViewModel(resource: Resource, resourceState: ResourceState) { + val id: String = resource.id + val type: ResourceType = resource.type + val address: String? = resource.address + val addressDescription: String? = resource.addressDescription + val sites: List? = resource.sites + val displayName: String = displayName(resource, resourceState) + val name: String = resource.name + val status: StatusEnum = resource.status + var state: ResourceState = resourceState +} + +fun displayName( + resource: Resource, + state: ResourceState, +): String { + return if (resource.isInternetResource()) { + internetResourceDisplayName(resource, state) + } else { + resource.name + } +} + +fun internetResourceDisplayName( + resource: Resource, + state: ResourceState, +): String { + return "${state.stateSymbol()} ${resource.name}" +} + +fun ResourceViewModel.isInternetResource(): Boolean { + return this.type == ResourceType.Internet +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt index 3f087ec3d..cd750d88f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt @@ -8,11 +8,13 @@ import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import dev.firezone.android.core.data.ResourceState import dev.firezone.android.databinding.ListItemResourceBinding -internal class ResourcesAdapter(private val activity: SessionActivity) : ListAdapter( - ResourceDiffCallback(), -) { +internal class ResourcesAdapter(private val internetResourceToggle: () -> ResourceState) : + ListAdapter( + ResourceDiffCallback(), + ) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, @@ -31,14 +33,14 @@ internal class ResourcesAdapter(private val activity: SessionActivity) : ListAda // Show bottom sheet val fragmentManager = (holder.itemView.context as AppCompatActivity).supportFragmentManager - val bottomSheet = ResourceDetailsBottomSheet(resource, activity) + val bottomSheet = ResourceDetailsBottomSheet(resource, internetResourceToggle) bottomSheet.show(fragmentManager, "ResourceDetailsBottomSheet") } } class ViewHolder(private val binding: ListItemResourceBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(resource: ViewResource) { - binding.resourceNameText.text = resource.name + fun bind(resource: ResourceViewModel) { + binding.resourceNameText.text = resource.displayName if (resource.isInternetResource()) { binding.addressText.visibility = View.GONE } else { @@ -47,17 +49,17 @@ internal class ResourcesAdapter(private val activity: SessionActivity) : ListAda } } - class ResourceDiffCallback : DiffUtil.ItemCallback() { + class ResourceDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: ViewResource, - newItem: ViewResource, + oldItem: ResourceViewModel, + newItem: ResourceViewModel, ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( - oldItem: ViewResource, - newItem: ViewResource, + oldItem: ResourceViewModel, + newItem: ResourceViewModel, ): Boolean { return oldItem == newItem } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt index 785947966..322e22321 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt @@ -15,6 +15,8 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.tabs.TabLayout import dagger.hilt.android.AndroidEntryPoint +import dev.firezone.android.core.data.ResourceState +import dev.firezone.android.core.data.toggle import dev.firezone.android.databinding.ActivitySessionBinding import dev.firezone.android.features.settings.ui.SettingsActivity import dev.firezone.android.tunnel.TunnelService @@ -47,7 +49,7 @@ class SessionActivity : AppCompatActivity() { } } - private val resourcesAdapter = ResourcesAdapter(this) + private val resourcesAdapter = ResourcesAdapter { this.onInternetResourceToggled() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -72,9 +74,18 @@ class SessionActivity : AppCompatActivity() { super.onDestroy() } - fun onViewResourceToggled(resourceToggled: ViewResource) { - Log.d(TAG, "Resource toggled $resourceToggled") - tunnelService?.resourceToggled(resourceToggled) + fun internetState(): ResourceState { + return tunnelService?.internetState() ?: ResourceState.UNSET + } + + private fun onInternetResourceToggled(): ResourceState { + tunnelService?.let { + it.internetResourceToggled(internetState().toggle()) + refreshList() + Log.d(TAG, "Internet resource toggled ${internetState()}") + } + + return internetState() } private fun setupViews() { @@ -150,7 +161,7 @@ class SessionActivity : AppCompatActivity() { View.GONE } - resourcesAdapter.submitList(viewModel.resourcesList()) { + resourcesAdapter.submitList(viewModel.resourcesList(internetState())) { afterLoad() } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt index 344006e18..00e9a0fba 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt @@ -5,7 +5,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dev.firezone.android.core.data.Repository +import dev.firezone.android.core.data.ResourceState import dev.firezone.android.tunnel.TunnelService.Companion.State +import dev.firezone.android.tunnel.model.Resource +import dev.firezone.android.tunnel.model.isInternetResource import javax.inject.Inject @HiltViewModel @@ -16,14 +19,14 @@ internal class SessionViewModel internal lateinit var repo: Repository private val _favoriteResourcesLiveData = MutableLiveData>(HashSet()) private val _serviceStatusLiveData = MutableLiveData() - private val _resourcesLiveData = MutableLiveData>(emptyList()) + private val _resourcesLiveData = MutableLiveData>(emptyList()) private var showOnlyFavorites: Boolean = false val favoriteResourcesLiveData: MutableLiveData> get() = _favoriteResourcesLiveData val serviceStatusLiveData: MutableLiveData get() = _serviceStatusLiveData - val resourcesLiveData: MutableLiveData> + val resourcesLiveData: MutableLiveData> get() = _resourcesLiveData private val favoriteResources: HashSet @@ -56,8 +59,16 @@ internal class SessionViewModel fun clearToken() = repo.clearToken() // The subset of Resources to actually render - fun resourcesList(): List { - val resources = resourcesLiveData.value!! + fun resourcesList(isInternetResourceEnabled: ResourceState): List { + val resources = + resourcesLiveData.value!!.map { + if (it.isInternetResource()) { + ResourceViewModel(it, isInternetResourceEnabled) + } else { + ResourceViewModel(it, ResourceState.ENABLED) + } + } + return if (favoriteResources.isEmpty()) { resources } else if (showOnlyFavorites) { diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt deleted file mode 100644 index 75c0831c8..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ -package dev.firezone.android.features.session.ui - -import dev.firezone.android.tunnel.model.Resource -import dev.firezone.android.tunnel.model.ResourceType -import dev.firezone.android.tunnel.model.Site -import dev.firezone.android.tunnel.model.StatusEnum - -data class ViewResource( - val id: String, - val type: ResourceType, - val address: String?, - val addressDescription: String?, - val sites: List?, - val name: String, - val status: StatusEnum, - var enabled: Boolean, - var canBeDisabled: Boolean = true, -) - -fun Resource.toViewResource(enabled: Boolean): ViewResource { - return ViewResource( - id = this.id, - type = this.type, - address = this.address, - addressDescription = this.addressDescription, - sites = this.sites, - name = this.name, - status = this.status, - enabled = enabled, - canBeDisabled = this.canBeDisabled, - ) -} - -fun ViewResource.isInternetResource(): Boolean { - return this.type == ResourceType.Internet -} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt index ba6f2f5d9..7b9bb498b 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt @@ -22,11 +22,12 @@ import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import dagger.hilt.android.AndroidEntryPoint import dev.firezone.android.core.data.Repository -import dev.firezone.android.features.session.ui.ViewResource -import dev.firezone.android.features.session.ui.toViewResource +import dev.firezone.android.core.data.ResourceState +import dev.firezone.android.core.data.isEnabled import dev.firezone.android.tunnel.callback.ConnlibCallback import dev.firezone.android.tunnel.model.Cidr import dev.firezone.android.tunnel.model.Resource +import dev.firezone.android.tunnel.model.isInternetResource import java.nio.file.Files import java.nio.file.Paths import java.util.UUID @@ -49,9 +50,9 @@ class TunnelService : VpnService() { var tunnelIpv6Address: String? = null private var tunnelDnsAddresses: MutableList = mutableListOf() private var tunnelRoutes: MutableList = mutableListOf() - private var _tunnelResources: List = emptyList() + private var _tunnelResources: List = emptyList() private var _tunnelState: State = State.DOWN - private var disabledResources: MutableSet = mutableSetOf() + var resourceState: ResourceState = ResourceState.UNSET // For reacting to changes to the network private var networkCallback: NetworkMonitor? = null @@ -67,7 +68,7 @@ class TunnelService : VpnService() { var startedByUser: Boolean = false var connlibSessionPtr: Long? = null - var tunnelResources: List + var tunnelResources: List get() = _tunnelResources set(value) { _tunnelResources = value @@ -82,7 +83,7 @@ class TunnelService : VpnService() { // Used to update the UI when the SessionActivity is bound to this service private var serviceStateLiveData: MutableLiveData? = null - private var resourcesLiveData: MutableLiveData>? = null + private var resourcesLiveData: MutableLiveData>? = null // For binding the SessionActivity view to this service private val binder = LocalBinder() @@ -99,7 +100,7 @@ class TunnelService : VpnService() { object : ConnlibCallback { override fun onUpdateResources(resourceListJSON: String) { moshi.adapter>().fromJson(resourceListJSON)?.let { - tunnelResources = it.map { resource -> resource.toViewResource(!disabledResources.contains(resource.id)) } + tunnelResources = it resourcesUpdated() } } @@ -257,24 +258,33 @@ class TunnelService : VpnService() { super.onRevoke() } + fun internetState(): ResourceState { + return resourceState + } + + fun internetResource(): Resource? { + return tunnelResources.firstOrNull { it.isInternetResource() } + } + // UI updates for resources fun resourcesUpdated() { - val newResources = tunnelResources.associateBy { it.id } - val currentlyDisabled = disabledResources.filter { newResources[it]?.canBeDisabled ?: false } + val currentlyDisabled = + if (internetResource() != null && !resourceState.isEnabled()) { + setOf(internetResource()!!.id) + } else { + emptySet() + } connlibSessionPtr?.let { ConnlibSession.setDisabledResources(it, Gson().toJson(currentlyDisabled)) } } - fun resourceToggled(resource: ViewResource) { - if (!resource.enabled) { - disabledResources.add(resource.id) - } else { - disabledResources.remove(resource.id) - } + fun internetResourceToggled(state: ResourceState) { + resourceState = state + + repo.saveInternetResourceStateSync(resourceState) - repo.saveDisabledResourcesSync(disabledResources) resourcesUpdated() } @@ -304,7 +314,7 @@ class TunnelService : VpnService() { private fun connect() { val token = appRestrictions.getString("token") ?: repo.getTokenSync() val config = repo.getConfigSync() - disabledResources = repo.getDisabledResourcesSync().toMutableSet() + resourceState = repo.getInternetResourceStateSync() if (!token.isNullOrBlank()) { tunnelState = State.CONNECTING @@ -377,7 +387,7 @@ class TunnelService : VpnService() { serviceStateLiveData?.postValue(tunnelState) } - fun setResourcesLiveData(liveData: MutableLiveData>) { + fun setResourcesLiveData(liveData: MutableLiveData>) { resourcesLiveData = liveData // Update the newly bound SessionActivity with our current resources @@ -388,7 +398,7 @@ class TunnelService : VpnService() { serviceStateLiveData?.postValue(state) } - private fun updateResourcesLiveData(resources: List) { + private fun updateResourcesLiveData(resources: List) { resourcesLiveData?.postValue(resources) } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt index 339ecacc3..14411b0c3 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt @@ -16,9 +16,12 @@ data class Resource( val sites: List?, val name: String, val status: StatusEnum, - @Json(name = "can_be_disabled") val canBeDisabled: Boolean, ) : Parcelable +fun Resource.isInternetResource(): Boolean { + return this.type == ResourceType.Internet +} + enum class ResourceType { @Json(name = "dns") DNS, diff --git a/rust/connlib/shared/src/callbacks.rs b/rust/connlib/shared/src/callbacks.rs index f63056ce4..5c0b917fd 100644 --- a/rust/connlib/shared/src/callbacks.rs +++ b/rust/connlib/shared/src/callbacks.rs @@ -71,14 +71,6 @@ impl ResourceDescription { } } - pub fn can_be_disabled(&self) -> bool { - match self { - ResourceDescription::Dns(r) => r.can_be_disabled, - ResourceDescription::Cidr(r) => r.can_be_disabled, - ResourceDescription::Internet(r) => r.can_be_disabled, - } - } - pub fn is_internet_resource(&self) -> bool { matches!(self, ResourceDescription::Internet(_)) } @@ -99,7 +91,6 @@ pub struct ResourceDescriptionDns { pub sites: Vec, pub status: Status, - pub can_be_disabled: bool, } /// Description of a resource that maps to a CIDR. @@ -118,7 +109,6 @@ pub struct ResourceDescriptionCidr { pub sites: Vec, pub status: Status, - pub can_be_disabled: bool, } /// Description of an Internet resource @@ -131,7 +121,6 @@ pub struct ResourceDescriptionInternet { pub sites: Vec, pub status: Status, - pub can_be_disabled: bool, } impl PartialOrd for ResourceDescription { @@ -176,7 +165,6 @@ mod tests { id: "99ba0c1e-5189-4cfc-a4db-fd6cb1c937fd".parse().unwrap(), }], status: Status::Online, - can_be_disabled: false, }) } @@ -189,7 +177,6 @@ mod tests { id: "99ba0c1e-5189-4cfc-a4db-fd6cb1c937fd".parse().unwrap(), }], status: Status::Offline, - can_be_disabled: true, }) } diff --git a/rust/connlib/shared/src/messages/client.rs b/rust/connlib/shared/src/messages/client.rs index 3443e960e..3c7c29623 100644 --- a/rust/connlib/shared/src/messages/client.rs +++ b/rust/connlib/shared/src/messages/client.rs @@ -36,7 +36,6 @@ impl ResourceDescriptionDns { name: self.name, address_description: self.address_description, sites: self.sites, - can_be_disabled: false, status, } } @@ -67,14 +66,13 @@ impl ResourceDescriptionCidr { name: self.name, address_description: self.address_description, sites: self.sites, - can_be_disabled: false, status, } } } fn internet_resource_name() -> String { - "<-> Internet Resource".to_string() + "Internet Resource".to_string() } /// Description of an internet resource. @@ -90,9 +88,6 @@ pub struct ResourceDescriptionInternet { /// Sites for the internet resource #[serde(rename = "gateway_groups")] pub sites: Vec, - /// Whether or not resource can be disabled from UI - #[serde(default)] - pub can_be_disabled: bool, } impl ResourceDescriptionInternet { @@ -101,7 +96,6 @@ impl ResourceDescriptionInternet { name: self.name, id: self.id, sites: self.sites, - can_be_disabled: self.can_be_disabled, status, } } diff --git a/rust/connlib/tunnel/src/proptest.rs b/rust/connlib/tunnel/src/proptest.rs index e8b05b7e5..9ee594caf 100644 --- a/rust/connlib/tunnel/src/proptest.rs +++ b/rust/connlib/tunnel/src/proptest.rs @@ -78,13 +78,10 @@ pub fn cidr_resource( pub fn internet_resource( sites: impl Strategy>, ) -> impl Strategy { - (resource_id(), sites, any::()).prop_map(move |(id, sites, can_be_disabled)| { - ResourceDescriptionInternet { - name: "Internet Resource".to_string(), - id, - sites, - can_be_disabled, - } + (resource_id(), sites).prop_map(move |(id, sites)| ResourceDescriptionInternet { + name: "Internet Resource".to_string(), + id, + sites, }) } diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index 6155c5e7c..1e9af17cb 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -17,6 +17,7 @@ use firezone_headless_client::{ }; use secrecy::{ExposeSecret, SecretString}; use std::{ + collections::BTreeSet, path::PathBuf, str::FromStr, time::{Duration, Instant}, @@ -441,6 +442,16 @@ impl Status { | Status::WaitingForTunnel { .. } => true, } } + + fn internet_resource(&self) -> Option { + #[allow(clippy::wildcard_enum_match_arm)] + match self { + Status::TunnelReady { resources } => { + resources.iter().find(|r| r.is_internet_resource()).cloned() + } + _ => None, + } + } } struct Controller { @@ -629,12 +640,12 @@ impl Controller { self.refresh_favorite_resources().await?; } Req::SystemTrayMenu(TrayMenuEvent::RetryPortalConnection) => self.try_retry_connection().await?, - Req::SystemTrayMenu(TrayMenuEvent::EnableResource(resource_id)) => { - self.advanced_settings.disabled_resources.remove(&resource_id); + Req::SystemTrayMenu(TrayMenuEvent::EnableInternetResource) => { + self.advanced_settings.internet_resource_enabled = Some(true); self.update_disabled_resources().await?; } - Req::SystemTrayMenu(TrayMenuEvent::DisableResource(resource_id)) => { - self.advanced_settings.disabled_resources.insert(resource_id); + Req::SystemTrayMenu(TrayMenuEvent::DisableInternetResource) => { + self.advanced_settings.internet_resource_enabled = Some(false); self.update_disabled_resources().await?; } Req::SystemTrayMenu(TrayMenuEvent::ShowWindow(window)) => { @@ -792,15 +803,16 @@ impl Controller { async fn update_disabled_resources(&mut self) -> Result<()> { settings::save(&self.advanced_settings).await?; - let Status::TunnelReady { resources } = &self.status else { - bail!("Tunnel is not ready"); - }; + let internet_resource = self + .status + .internet_resource() + .context("Tunnel not ready")?; - let disabled_resources = resources - .iter() - .filter_map(|r| r.can_be_disabled().then_some(r.id())) - .filter(|id| self.advanced_settings.disabled_resources.contains(id)) - .collect(); + let mut disabled_resources = BTreeSet::new(); + + if !self.advanced_settings.internet_resource_enabled() { + disabled_resources.insert(internet_resource.id()); + } self.ipc_client .send_msg(&SetDisabledResources(disabled_resources)) @@ -832,7 +844,9 @@ impl Controller { system_tray::ConnlibState::SignedIn(system_tray::SignedIn { actor_name: &auth_session.actor_name, favorite_resources: &self.advanced_settings.favorite_resources, - disabled_resources: &self.advanced_settings.disabled_resources, + internet_resource_enabled: &self + .advanced_settings + .internet_resource_enabled, resources, }) } diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs index fe975dea8..7f5e3905e 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs @@ -35,6 +35,9 @@ const NO_ACTIVITY: &str = "[-] No activity"; const GATEWAY_CONNECTED: &str = "[O] Gateway connected"; const ALL_GATEWAYS_OFFLINE: &str = "[X] All Gateways offline"; +const ENABLED_SYMBOL: &str = "<->"; +const DISABLED_SYMBOL: &str = "—"; + const ADD_FAVORITE: &str = "Add to favorites"; const REMOVE_FAVORITE: &str = "Remove from favorites"; const FAVORITE_RESOURCES: &str = "Favorite Resources"; @@ -82,7 +85,7 @@ pub(crate) struct SignedIn<'a> { pub(crate) actor_name: &'a str, pub(crate) favorite_resources: &'a HashSet, pub(crate) resources: &'a [ResourceDescription], - pub(crate) disabled_resources: &'a HashSet, + pub(crate) internet_resource_enabled: &'a Option, } impl<'a> SignedIn<'a> { @@ -103,17 +106,17 @@ impl<'a> SignedIn<'a> { fn resource_submenu(&self, res: &ResourceDescription) -> Menu { let mut submenu = Menu::default().resource_description(res); - if !res.is_internet_resource() { - self.add_favorite_toggle(&mut submenu, res.id()); + if res.is_internet_resource() { + submenu.add_separator(); + if self.is_internet_resource_enabled() { + submenu.add_item(item(Event::DisableInternetResource, DISABLE)); + } else { + submenu.add_item(item(Event::EnableInternetResource, ENABLE)); + } } - if res.can_be_disabled() { - submenu.add_separator(); - if self.is_enabled(res) { - submenu.add_item(item(Event::DisableResource(res.id()), DISABLE)); - } else { - submenu.add_item(item(Event::EnableResource(res.id()), ENABLE)); - } + if !res.is_internet_resource() { + self.add_favorite_toggle(&mut submenu, res.id()); } if let Some(site) = res.sites().first() { @@ -134,8 +137,8 @@ impl<'a> SignedIn<'a> { } } - fn is_enabled(&self, res: &ResourceDescription) -> bool { - !self.disabled_resources.contains(&res.id()) + fn is_internet_resource_enabled(&self) -> bool { + self.internet_resource_enabled.unwrap_or_default() } } @@ -259,11 +262,22 @@ impl<'a> AppState<'a> { } } +fn append_status(name: &str, enabled: bool) -> String { + let symbol = if enabled { + ENABLED_SYMBOL + } else { + DISABLED_SYMBOL + }; + + format!("{symbol} {name}") +} + fn signed_in(signed_in: &SignedIn) -> Menu { let SignedIn { actor_name, favorite_resources, resources, // Make sure these are presented in the order we receive them + internet_resource_enabled, .. } = signed_in; @@ -288,7 +302,12 @@ fn signed_in(signed_in: &SignedIn) -> Menu { .iter() .filter(|res| favorite_resources.contains(&res.id()) || res.is_internet_resource()) { - menu = menu.add_submenu(res.name(), signed_in.resource_submenu(res)); + let mut name = res.name().to_string(); + if res.is_internet_resource() { + name = append_status(&name, internet_resource_enabled.unwrap_or_default()); + } + + menu = menu.add_submenu(name, signed_in.resource_submenu(res)); } } else { // No favorites, show every Resource normally, just like before @@ -296,7 +315,12 @@ fn signed_in(signed_in: &SignedIn) -> Menu { // Always show Resources in the original order menu = menu.disabled(RESOURCES); for res in *resources { - menu = menu.add_submenu(res.name(), signed_in.resource_submenu(res)); + let mut name = res.name().to_string(); + if res.is_internet_resource() { + name = append_status(&name, internet_resource_enabled.unwrap_or_default()); + } + + menu = menu.add_submenu(name, signed_in.resource_submenu(res)); } } @@ -387,14 +411,14 @@ mod tests { fn signed_in<'a>( resources: &'a [ResourceDescription], favorite_resources: &'a HashSet, - disabled_resources: &'a HashSet, + internet_resource_enabled: &'a Option, ) -> AppState<'a> { AppState { connlib: ConnlibState::SignedIn(SignedIn { actor_name: "Jane Doe", favorite_resources, resources, - disabled_resources, + internet_resource_enabled, }), release: None, } @@ -409,8 +433,7 @@ mod tests { "address": "172.172.0.0/16", "address_description": "cidr resource", "sites": [{"name": "test", "id": "bf56f32d-7b2c-4f5d-a784-788977d014a4"}], - "status": "Unknown", - "can_be_disabled": false + "status": "Unknown" }, { "id": "03000143-e25e-45c7-aafb-144990e57dcd", @@ -419,8 +442,7 @@ mod tests { "address": "gitlab.mycorp.com", "address_description": "https://gitlab.mycorp.com", "sites": [{"name": "test", "id": "bf56f32d-7b2c-4f5d-a784-788977d014a4"}], - "status": "Online", - "can_be_disabled": false + "status": "Online" }, { "id": "1106047c-cd5d-4151-b679-96b93da7383b", @@ -428,8 +450,7 @@ mod tests { "name": "Internet Resource", "address": "All internet addresses", "sites": [{"name": "test", "id": "eb94482a-94f4-47cb-8127-14fb3afa5516"}], - "status": "Offline", - "can_be_disabled": false + "status": "Offline" } ]"#; @@ -534,10 +555,12 @@ mod tests { .copyable(GATEWAY_CONNECTED), ) .add_submenu( - "Internet Resource", + "— Internet Resource", Menu::default() .disabled(INTERNET_RESOURCE_DESCRIPTION) .separator() + .item(Event::EnableInternetResource, ENABLE) + .separator() .disabled("Site") .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), @@ -588,10 +611,12 @@ mod tests { .copyable(GATEWAY_CONNECTED), ) .add_submenu( - "Internet Resource", + "— Internet Resource", Menu::default() .disabled(INTERNET_RESOURCE_DESCRIPTION) .separator() + .item(Event::EnableInternetResource, ENABLE) + .separator() .disabled("Site") .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), @@ -687,10 +712,12 @@ mod tests { .copyable(GATEWAY_CONNECTED), ) .add_submenu( - "Internet Resource", + "— Internet Resource", Menu::default() .disabled(INTERNET_RESOURCE_DESCRIPTION) .separator() + .item(Event::EnableInternetResource, ENABLE) + .separator() .disabled("Site") .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs index 6948de95c..d2f8169a5 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs @@ -67,10 +67,10 @@ pub(crate) enum Event { Url(Url), /// Quits the app, without signing the user out Quit, - /// A resource was enabled in the UI - EnableResource(ResourceId), - /// A resource was disabled in the UI - DisableResource(ResourceId), + /// The internet resource was enabled + EnableInternetResource, + /// The internet resource was disabled + DisableInternetResource, } #[derive(Debug, Deserialize, PartialEq, Serialize)] diff --git a/rust/gui-client/src-tauri/src/client/settings.rs b/rust/gui-client/src-tauri/src/client/settings.rs index ff0e4140d..328ab36e7 100644 --- a/rust/gui-client/src-tauri/src/client/settings.rs +++ b/rust/gui-client/src-tauri/src/client/settings.rs @@ -18,7 +18,7 @@ pub(crate) struct AdvancedSettings { #[serde(default)] pub favorite_resources: HashSet, #[serde(default)] - pub disabled_resources: HashSet, + pub internet_resource_enabled: Option, pub log_filter: String, } @@ -29,7 +29,7 @@ impl Default for AdvancedSettings { auth_base_url: Url::parse("https://app.firez.one").unwrap(), api_url: Url::parse("wss://api.firez.one").unwrap(), favorite_resources: Default::default(), - disabled_resources: Default::default(), + internet_resource_enabled: Default::default(), log_filter: "firezone_gui_client=debug,info".to_string(), } } @@ -42,12 +42,18 @@ impl Default for AdvancedSettings { auth_base_url: Url::parse("https://app.firezone.dev").unwrap(), api_url: Url::parse("wss://api.firezone.dev").unwrap(), favorite_resources: Default::default(), - disabled_resources: Default::default(), + internet_resource_enabled: Default::default(), log_filter: "info".to_string(), } } } +impl AdvancedSettings { + pub fn internet_resource_enabled(&self) -> bool { + self.internet_resource_enabled.is_some_and(|v| v) + } +} + pub(crate) fn advanced_settings_path() -> Result { Ok(known_dirs::settings() .context("`known_dirs::settings` failed")? diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift index cb2814804..c9be6aa2a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift @@ -20,13 +20,13 @@ public enum TunnelManagerKeys { static let authBaseURL = "authBaseURL" static let apiURL = "apiURL" public static let logFilter = "logFilter" - public static let disabledResources = "disabledResources" + public static let internetResourceEnabled = "internetResourceEnabled" } public enum TunnelMessage: Codable { case getResourceList(Data) case signOut - case setDisabledResources(Set) + case internetResourceEnabled(Bool) enum CodingKeys: String, CodingKey { case type @@ -36,16 +36,16 @@ public enum TunnelMessage: Codable { enum MessageType: String, Codable { case getResourceList case signOut - case setDisabledResources + case internetResourceEnabled } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(MessageType.self, forKey: .type) switch type { - case .setDisabledResources: - let value = try container.decode(Set.self, forKey: .value) - self = .setDisabledResources(value) + case .internetResourceEnabled: + let value = try container.decode(Bool.self, forKey: .value) + self = .internetResourceEnabled(value) case .getResourceList: let value = try container.decode(Data.self, forKey: .value) self = .getResourceList(value) @@ -56,9 +56,9 @@ public enum TunnelMessage: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .setDisabledResources(let value): - try container.encode(MessageType.setDisabledResources, forKey: .type) - try container.encode(value, forKey: .value) + case .internetResourceEnabled(let value): + try container.encode(MessageType.internetResourceEnabled, forKey: .type) + try container.encode(value, forKey: .value) case .getResourceList(let value): try container.encode(MessageType.getResourceList, forKey: .type) try container.encode(value, forKey: .value) @@ -87,8 +87,8 @@ public class TunnelManager { // Persists our tunnel settings private var manager: NETunnelProviderManager? - // Resources that are currently disabled and will not be used - public var disabledResources: Set = [] + // Indicates if the internet resource is currently enabled + public var internetResourceEnabled: Bool = false // Encoder used to send messages to the tunnel private let encoder = PropertyListEncoder() @@ -154,8 +154,9 @@ public class TunnelManager { // Found it let settings = Settings.fromProviderConfiguration(providerConfiguration) let actorName = providerConfiguration[TunnelManagerKeys.actorName] - if let disabledResourcesData = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) { - self.disabledResources = (try? JSONDecoder().decode(Set.self, from: disabledResourcesData)) ?? Set() + if let internetResourceEnabled = providerConfiguration[TunnelManagerKeys.internetResourceEnabled]?.data(using: .utf8) { + + self.internetResourceEnabled = (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabled)) ?? false } let status = manager.connection.status @@ -256,20 +257,15 @@ public class TunnelManager { } } - func updateDisabledResources() { + func updateInternetResourceState() { guard session().status == .connected else { return } - try? session().sendProviderMessage(encoder.encode(TunnelMessage.setDisabledResources(disabledResources))) { _ in } + try? session().sendProviderMessage(encoder.encode(TunnelMessage.internetResourceEnabled(internetResourceEnabled))) { _ in } } - func toggleResourceDisabled(resource: String, enabled: Bool) { - if enabled { - disabledResources.remove(resource) - } else { - disabledResources.insert(resource) - } - - updateDisabledResources() + func toggleInternetResource(enabled: Bool) { + internetResourceEnabled = enabled + updateInternetResourceState() } func fetchResources(callback: @escaping (ResourceList) -> Void) { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift index ca8a49788..531428cf8 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Resource.swift @@ -8,6 +8,11 @@ import Foundation +class StatusSymbol { + static var on: String = "<->" + static var off: String = "—" +} + public enum ResourceList { case loading case loaded([Resource]) @@ -30,9 +35,8 @@ public struct Resource: Decodable, Identifiable, Equatable { public var status: ResourceStatus public var sites: [Site] public var type: ResourceType - public var canBeDisabled: Bool - public init(id: String, name: String, address: String?, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType, canBeDisabled: Bool) { + public init(id: String, name: String, address: String?, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType) { self.id = id self.name = name self.address = address @@ -40,7 +44,6 @@ public struct Resource: Decodable, Identifiable, Equatable { self.status = status self.sites = sites self.type = type - self.canBeDisabled = canBeDisabled } public func isInternetResource() -> Bool { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift index 58951a1fb..179ee5ef8 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -10,7 +10,7 @@ struct Settings: Equatable { var authBaseURL: String var apiURL: String var logFilter: String - var disabledResources: Set + var internetResourceEnabled: Bool? var isValid: Bool { let authBaseURL = URL(string: authBaseURL) @@ -38,19 +38,17 @@ struct Settings: Equatable { ?? Settings.defaultValue.apiURL, logFilter: providerConfiguration[TunnelManagerKeys.logFilter] ?? Settings.defaultValue.logFilter, - disabledResources: getDisabledResources(disabledResources: providerConfiguration[TunnelManagerKeys.disabledResources]) + internetResourceEnabled: getInternetResourceEnabled(internetResourceEnabled: providerConfiguration[TunnelManagerKeys.internetResourceEnabled]) ) } else { return Settings.defaultValue } } - static private func getDisabledResources(disabledResources: String?) -> Set { - guard let disabledResourcesJSON = disabledResources, let disabledResourcesData = disabledResourcesJSON.data(using: .utf8) else{ - return Set() - } - return (try? JSONDecoder().decode(Set.self, from: disabledResourcesData)) - ?? Settings.defaultValue.disabledResources + static private func getInternetResourceEnabled(internetResourceEnabled: String?) -> Bool? { + guard let internetResourceEnabled = internetResourceEnabled, let jsonData = internetResourceEnabled.data(using: .utf8) else { return nil } + + return try? JSONDecoder().decode(Bool?.self, from: jsonData) } // Used for initializing a new providerConfiguration from Settings @@ -59,7 +57,7 @@ struct Settings: Equatable { TunnelManagerKeys.authBaseURL: authBaseURL, TunnelManagerKeys.apiURL: apiURL, TunnelManagerKeys.logFilter: logFilter, - TunnelManagerKeys.disabledResources: String(data: try! JSONEncoder().encode(disabledResources), encoding: .utf8) ?? "", + TunnelManagerKeys.internetResourceEnabled: String(data: try! JSONEncoder().encode(internetResourceEnabled) , encoding: .utf8)!, ] } @@ -72,14 +70,14 @@ struct Settings: Equatable { apiURL: "wss://api.firez.one", logFilter: "firezone_tunnel=debug,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn", - disabledResources: Set() + internetResourceEnabled: nil ) #else Settings( authBaseURL: "https://app.firezone.dev", apiURL: "wss://api.firezone.dev", logFilter: "str0m=warn,info", - disabledResources: Set() + internetResourceEnabled: nil ) #endif }() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index a7dabf8f5..afeb20bf2 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -45,8 +45,8 @@ public final class Store: ObservableObject { initTunnelManager() } - public func isResourceEnabled(_ id: String) -> Bool { - !tunnelManager.disabledResources.contains(id) + public func internetResourceEnabled() -> Bool { + tunnelManager.internetResourceEnabled } private func initNotifications() { @@ -171,10 +171,10 @@ public final class Store: ObservableObject { } } - func toggleResourceDisabled(resource: String, enabled: Bool) { - tunnelManager.toggleResourceDisabled(resource: resource, enabled: enabled) + func toggleInternetResource(enabled: Bool) { + tunnelManager.toggleInternetResource(enabled: enabled) var newSettings = settings - newSettings.disabledResources = tunnelManager.disabledResources + newSettings.internetResourceEnabled = tunnelManager.internetResourceEnabled Task { try await save(newSettings) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 307ae7ed5..c27a09cf7 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -22,6 +22,9 @@ public final class MenuBar: NSObject, ObservableObject { // Wish these could be `[String]` but diffing between different types is tricky private var lastShownFavorites: [Resource] = [] private var lastShownOthers: [Resource] = [] + + private var wasInternetResourceEnabled: Bool = false + private var cancellables: Set = [] @ObservedObject var model: SessionViewModel @@ -432,12 +435,21 @@ public final class MenuBar: NSObject, ObservableObject { populateOtherResourcesMenu(newOthers) } + private func displayNameChanged(_ resource: Resource) -> Bool { + if !resource.isInternetResource() { + return false + } + + return wasInternetResourceEnabled != model.store.internetResourceEnabled() + } + private func populateFavoriteResourcesMenu(_ newFavorites: [Resource]) { // Update the menu in place so everything won't vanish if it's open when it updates let diff = (newFavorites).difference( from: lastShownFavorites, - by: { $0 == $1 } + by: { $0 == $1 && !displayNameChanged($0) } ) + let index = menu.index(of: resourcesTitleMenuItem) + 1 for change in diff { switch change { @@ -449,6 +461,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownFavorites = newFavorites + wasInternetResourceEnabled = model.store.internetResourceEnabled() } private func populateOtherResourcesMenu(_ newOthers: [Resource]) { @@ -464,7 +477,7 @@ public final class MenuBar: NSObject, ObservableObject { // Update the menu in place so everything won't vanish if it's open when it updates let diff = (newOthers).difference( from: lastShownOthers, - by: { $0 == $1 } + by: { $0 == $1 && !displayNameChanged($0) } ) for change in diff { switch change { @@ -476,6 +489,8 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownOthers = newOthers + wasInternetResourceEnabled = model.store.internetResourceEnabled() + } private func addItemToMenu(menu: NSMenu, item: NSMenuItem, at: Int) { @@ -498,8 +513,22 @@ public final class MenuBar: NSObject, ObservableObject { menu.removeItem(item) } + private func internetResourceTitle(resource: Resource) -> String { + let status = model.store.internetResourceEnabled() ? StatusSymbol.on : StatusSymbol.off + + return status + " " + resource.name + } + + private func resourceTitle(resource: Resource) -> String { + if resource.isInternetResource() { + return internetResourceTitle(resource: resource) + } + + return resource.name + } + private func createResourceMenuItem(resource: Resource) -> NSMenuItem { - let item = NSMenuItem(title: resource.name, action: nil, keyEquivalent: "") + let item = NSMenuItem(title: resourceTitle(resource: resource), action: nil, keyEquivalent: "") item.isHidden = false item.submenu = createSubMenu(resource: resource) @@ -507,8 +536,8 @@ public final class MenuBar: NSObject, ObservableObject { return item } - private func resourceTitle(_ id: String) -> String { - model.isResourceEnabled(id) ? "Disable this resource" : "Enable this resource" + private func internetResourceToggleTitle() -> String { + model.isInternetResourceEnabled() ? "Disable this resource" : "Enable this resource" } private func nonInternetResourceHeader(resource: Resource) -> NSMenu { @@ -595,6 +624,16 @@ public final class MenuBar: NSObject, ObservableObject { subMenu.addItem(description) + // Resource enable / disable toggle + subMenu.addItem(NSMenuItem.separator()) + let enableToggle = NSMenuItem() + enableToggle.action = #selector(internetResourceToggle(_:)) + enableToggle.title = internetResourceToggleTitle() + enableToggle.toolTip = "Enable or disable resource" + enableToggle.isEnabled = true + enableToggle.target = self + subMenu.addItem(enableToggle) + return subMenu } @@ -610,22 +649,9 @@ public final class MenuBar: NSObject, ObservableObject { let siteSectionItem = NSMenuItem() let siteNameItem = NSMenuItem() let siteStatusItem = NSMenuItem() - let enableToggle = NSMenuItem() let subMenu = resourceHeader(resource: resource) - // Resource enable / disable toggle - if resource.canBeDisabled { - subMenu.addItem(NSMenuItem.separator()) - enableToggle.action = #selector(resourceToggle(_:)) - enableToggle.title = resourceTitle(resource.id) - enableToggle.toolTip = "Enable or disable resource" - enableToggle.isEnabled = true - enableToggle.target = self - enableToggle.representedObject = resource.id - subMenu.addItem(enableToggle) - } - // Site details if let site = resource.sites.first { subMenu.addItem(NSMenuItem.separator()) @@ -668,11 +694,9 @@ public final class MenuBar: NSObject, ObservableObject { } } - @objc private func resourceToggle(_ sender: NSMenuItem) { - let id = sender.representedObject as! String - - self.model.store.toggleResourceDisabled(resource: id, enabled: !model.isResourceEnabled(id)) - sender.title = resourceTitle(id) + @objc private func internetResourceToggle(_ sender: NSMenuItem) { + self.model.store.toggleInternetResource(enabled: !model.store.internetResourceEnabled()) + sender.title = internetResourceToggleTitle() } @objc private func resourceURLTapped(_ sender: AnyObject?) { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index 40fadfc0e..66b7274bd 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -173,9 +173,6 @@ struct NonInternetResourceHeader: View { } } } - - ToggleResourceEnabledButton(resource: resource, model: model) - } } } @@ -204,17 +201,18 @@ struct InternetResourceHeader: View { Text("All network traffic") } - ToggleResourceEnabledButton(resource: resource, model: model) + + ToggleInternetResourceButton(resource: resource, model: model) } } } -struct ToggleResourceEnabledButton: View { +struct ToggleInternetResourceButton: View { var resource: Resource @ObservedObject var model: SessionViewModel private func toggleResourceEnabledText() -> String { - if model.isResourceEnabled(resource.id) { + if model.isInternetResourceEnabled() { "Disable this resource" } else { "Enable this resource" @@ -222,14 +220,12 @@ struct ToggleResourceEnabledButton: View { } var body: some View { - if resource.canBeDisabled { - Button(action: { - model.store.toggleResourceDisabled(resource: resource.id, enabled: !model.isResourceEnabled(resource.id)) - }) { - HStack { - Text(toggleResourceEnabledText()) - Spacer() - } + Button(action: { + model.store.toggleInternetResource(enabled: !model.isInternetResourceEnabled()) + }) { + HStack { + Text(toggleResourceEnabledText()) + Spacer() } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index 989f761d3..7746408c4 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -60,8 +60,8 @@ public final class SessionViewModel: ObservableObject { .store(in: &cancellables) } - public func isResourceEnabled(_ resource: String) -> Bool { - store.isResourceEnabled(resource) + public func isInternetResourceEnabled() -> Bool { + store.internetResourceEnabled() } } @@ -127,12 +127,26 @@ struct ResourceSection: View { let resources: [Resource] @ObservedObject var model: SessionViewModel + private func internetResourceTitle(resource: Resource) -> String { + let status = model.store.internetResourceEnabled() ? StatusSymbol.on : StatusSymbol.off + + return status + " " + resource.name + } + + private func resourceTitle(resource: Resource) -> String { + if resource.isInternetResource() { + return internetResourceTitle(resource: resource) + } + + return resource.name + } + var body: some View { ForEach(resources) { resource in HStack { NavigationLink { ResourceView(model: model, resource: resource) } label: { - Text(resource.name) + Text(resourceTitle(resource: resource)) } } .navigationTitle("All Resources") diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 91f22b46a..30993325a 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -61,10 +61,10 @@ class Adapter { private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue") /// Currently disabled resources - private var disabledResources: Set = [] + private var internetResourceEnabled: Bool = false - /// Cache of resources that can be disabled - private var canBeDisabled: Set = [] + /// Cache of internet resource + private var internetResource: Resource? /// Adapter state. private var state: AdapterState { @@ -86,7 +86,7 @@ class Adapter { apiURL: String, token: String, logFilter: String, - disabledResources: Set, + internetResourceEnabled: Bool, packetTunnelProvider: PacketTunnelProvider ) { self.apiURL = apiURL @@ -97,7 +97,7 @@ class Adapter { self.logFilter = logFilter self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? "" self.networkSettings = nil - self.disabledResources = disabledResources + self.internetResourceEnabled = internetResourceEnabled } // Could happen abruptly if the process is killed. @@ -204,10 +204,10 @@ class Adapter { return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? [] } - public func setDisabledResources(newDisabledResources: Set) { + public func setInternetResourceEnabled(_ enabled: Bool) { workQueue.async { [weak self] in guard let self = self else { return } - self.disabledResources = newDisabledResources + self.internetResourceEnabled = enabled self.resourcesUpdated() } } @@ -218,9 +218,13 @@ class Adapter { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - canBeDisabled = Set(resources().filter({ $0.canBeDisabled }).map({ $0.id })) + internetResource = resources().filter{ $0.isInternetResource() }.first + + var disablingResources: Set = [] + if let internetResource = internetResource, !internetResourceEnabled { + disablingResources.insert(internetResource.id) + } - let disablingResources = disabledResources.filter({ canBeDisabled.contains($0) }) let currentlyDisabled = try! JSONEncoder().encode(disablingResources) session.setDisabledResources(String(data: currentlyDisabled, encoding: .utf8)!) diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 266647713..9262098c4 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -78,14 +78,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - let disabledResources: Set = if let disabledResourcesJSON = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) { - (try? JSONDecoder().decode(Set.self, from: disabledResourcesJSON )) ?? Set() + let internetResourceEnabled: Bool = if let internetResourceEnabledJSON = providerConfiguration[TunnelManagerKeys.internetResourceEnabled]?.data(using: .utf8) { + (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false } else { - Set() + false } let adapter = Adapter( - apiURL: apiURL, token: token, logFilter: logFilter, disabledResources: disabledResources, packetTunnelProvider: self) + apiURL: apiURL, token: token, logFilter: logFilter, internetResourceEnabled: internetResourceEnabled, packetTunnelProvider: self) self.adapter = adapter @@ -139,8 +139,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return } switch tunnelMessage { - case .setDisabledResources(let value): - adapter?.setDisabledResources(newDisabledResources: value) + case .internetResourceEnabled(let value): + adapter?.setInternetResourceEnabled(value) case .signOut: Task { await clearToken()