mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat: Internet Resource UI (#6434)
Fixes #6047 On mobile platforms the internet resource is rendered with all non-favorite resources, since it was weird to see within the favorite tab, for the system tray platforms it's rendered as part of favorites if there is any favorite so that it's always visible to the user. For mobile platforms the resource is non-clickeable, since the menu shouldn't be of interest(maybe I should add it only for the sites?). For non-mobile there is a sub menu where you can find the sites and the enable/disable. The current label for the resource is a place holder for the screenshots, and can be set by the portal, if the portal doesn't set any name it will just show "Internet Resource". ### Android screenshot  ### Linux Screenshot   ### Windows Screenshot  ### MacOS screenshot <img width="417" alt="image" src="https://github.com/user-attachments/assets/5488d6e4-1cd2-42be-bcd7-3c51ec295590"> ### iOS screenshot 
This commit is contained in:
@@ -83,7 +83,7 @@ class ResourceDetailsBottomSheet(private val resource: ViewResource) : BottomShe
|
||||
}
|
||||
} else {
|
||||
resourceAddressTextView.setOnClickListener {
|
||||
copyToClipboard(displayAddress)
|
||||
copyToClipboard(displayAddress!!)
|
||||
Toast.makeText(requireContext(), "Address copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package dev.firezone.android.features.session.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isVisible
|
||||
@@ -29,12 +30,15 @@ internal class ResourcesAdapter(private val activity: SessionActivity) : ListAda
|
||||
) {
|
||||
val resource = getItem(position)
|
||||
holder.bind(resource) { newResource -> onSwitchToggled(newResource) }
|
||||
holder.itemView.setOnClickListener {
|
||||
// Show bottom sheet
|
||||
val isFavorite = favoriteResources.contains(resource.id)
|
||||
val fragmentManager = (holder.itemView.context as AppCompatActivity).supportFragmentManager
|
||||
val bottomSheet = ResourceDetailsBottomSheet(resource)
|
||||
bottomSheet.show(fragmentManager, "ResourceDetailsBottomSheet")
|
||||
if (!resource.isInternetResource()) {
|
||||
holder.itemView.setOnClickListener {
|
||||
// Show bottom sheet
|
||||
val isFavorite = favoriteResources.contains(resource.id)
|
||||
val fragmentManager =
|
||||
(holder.itemView.context as AppCompatActivity).supportFragmentManager
|
||||
val bottomSheet = ResourceDetailsBottomSheet(resource)
|
||||
bottomSheet.show(fragmentManager, "ResourceDetailsBottomSheet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +52,11 @@ internal class ResourcesAdapter(private val activity: SessionActivity) : ListAda
|
||||
onSwitchToggled: (ViewResource) -> Unit,
|
||||
) {
|
||||
binding.resourceNameText.text = resource.name
|
||||
binding.addressText.text = resource.address
|
||||
if (resource.isInternetResource()) {
|
||||
binding.addressText.visibility = View.GONE
|
||||
} else {
|
||||
binding.addressText.text = resource.address
|
||||
}
|
||||
// Without this the item gets reset when out of view, isn't android wonderful?
|
||||
binding.enableSwitch.setOnCheckedChangeListener(null)
|
||||
binding.enableSwitch.isChecked = resource.enabled
|
||||
|
||||
@@ -101,7 +101,11 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
object : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
viewModel.tabSelected(tab.position)
|
||||
refreshList()
|
||||
|
||||
refreshList {
|
||||
// TODO: we might want to remember the old position?
|
||||
binding.rvResourcesList.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) {}
|
||||
@@ -130,7 +134,7 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
viewModel.favoriteResourcesLiveData.value = viewModel.repo.getFavoritesSync()
|
||||
}
|
||||
|
||||
private fun refreshList() {
|
||||
private fun refreshList(afterLoad: () -> Unit = {}) {
|
||||
if (viewModel.forceAllResourcesTab()) {
|
||||
binding.tabLayout.selectTab(binding.tabLayout.getTabAt(SessionViewModel.RESOURCES_TAB_ALL), true)
|
||||
}
|
||||
@@ -141,7 +145,9 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
resourcesAdapter.submitList(viewModel.resourcesList())
|
||||
resourcesAdapter.submitList(viewModel.resourcesList()) {
|
||||
afterLoad()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
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 address: String,
|
||||
val type: ResourceType,
|
||||
val address: String?,
|
||||
val addressDescription: String?,
|
||||
val sites: List<Site>?,
|
||||
val name: String,
|
||||
@@ -19,6 +21,7 @@ data class ViewResource(
|
||||
fun Resource.toViewResource(enabled: Boolean): ViewResource {
|
||||
return ViewResource(
|
||||
id = this.id,
|
||||
type = this.type,
|
||||
address = this.address,
|
||||
addressDescription = this.addressDescription,
|
||||
sites = this.sites,
|
||||
@@ -28,3 +31,7 @@ fun Resource.toViewResource(enabled: Boolean): ViewResource {
|
||||
canBeDisabled = this.canBeDisabled,
|
||||
)
|
||||
}
|
||||
|
||||
fun ViewResource.isInternetResource(): Boolean {
|
||||
return this.type == ResourceType.Internet
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import kotlinx.parcelize.Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Resource(
|
||||
val type: TypeEnum,
|
||||
val type: ResourceType,
|
||||
val id: String,
|
||||
val address: String,
|
||||
val address: String?,
|
||||
@Json(name = "address_description") val addressDescription: String?,
|
||||
val sites: List<Site>?,
|
||||
val name: String,
|
||||
@@ -20,7 +20,7 @@ data class Resource(
|
||||
@Json(name = "can_be_disabled") val canBeDisabled: Boolean,
|
||||
) : Parcelable
|
||||
|
||||
enum class TypeEnum {
|
||||
enum class ResourceType {
|
||||
@Json(name = "dns")
|
||||
DNS,
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
style="@style/AppTheme.Base.Body1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toStartOf="@+id/enable_switch"
|
||||
app:layout_constraintEnd_toStartOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Resource Name" />
|
||||
@@ -24,24 +24,17 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_600"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/enable_switch"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/resourceNameText"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:text="Resource Address" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/enable_switch"
|
||||
android:layout_width="101dp"
|
||||
android:layout_height="39dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="wrap_content"
|
||||
android:checked="true"
|
||||
android:minHeight="48dp"
|
||||
android:scaleX="1"
|
||||
android:scaleY="1"
|
||||
android:text="Enabled"
|
||||
android:textAlignment="viewStart"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:gravity="center_vertical"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/resourceNameText"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ResourceDescription {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_internet_resource(&self) -> bool {
|
||||
pub fn is_internet_resource(&self) -> bool {
|
||||
matches!(self, ResourceDescription::Internet(_))
|
||||
}
|
||||
}
|
||||
@@ -127,9 +127,6 @@ pub struct ResourceDescriptionInternet {
|
||||
/// Name for display always set to "Internet Resource"
|
||||
pub name: String,
|
||||
|
||||
/// Address for display always set to "All internet addresses"
|
||||
pub address: String,
|
||||
|
||||
pub id: ResourceId,
|
||||
pub sites: Vec<Site>,
|
||||
|
||||
@@ -186,7 +183,6 @@ mod tests {
|
||||
fn internet_resource(uuid: &str) -> ResourceDescription {
|
||||
ResourceDescription::Internet(ResourceDescriptionInternet {
|
||||
name: "Internet Resource".to_string(),
|
||||
address: "All internet addresses".to_string(),
|
||||
id: ResourceId::from_str(uuid).unwrap(),
|
||||
sites: vec![Site {
|
||||
name: "test".to_string(),
|
||||
|
||||
@@ -73,9 +73,18 @@ impl ResourceDescriptionCidr {
|
||||
}
|
||||
}
|
||||
|
||||
fn internet_resource_name() -> String {
|
||||
"<-> Internet Resource".to_string()
|
||||
}
|
||||
|
||||
/// Description of an internet resource.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ResourceDescriptionInternet {
|
||||
/// Name of the resource.
|
||||
///
|
||||
/// Used only for display.
|
||||
#[serde(default = "internet_resource_name")]
|
||||
pub name: String,
|
||||
/// Resource's id.
|
||||
pub id: ResourceId,
|
||||
/// Sites for the internet resource
|
||||
@@ -88,8 +97,7 @@ pub struct ResourceDescriptionInternet {
|
||||
impl ResourceDescriptionInternet {
|
||||
pub fn with_status(self, status: Status) -> crate::callbacks::ResourceDescriptionInternet {
|
||||
crate::callbacks::ResourceDescriptionInternet {
|
||||
name: "Internet Resource".to_string(),
|
||||
address: "All internet addresses".to_string(),
|
||||
name: self.name,
|
||||
id: self.id,
|
||||
sites: self.sites,
|
||||
can_be_disabled: self.can_be_disabled.unwrap_or_default(),
|
||||
@@ -269,6 +277,7 @@ mod tests {
|
||||
{
|
||||
"id": "1106047c-cd5d-4151-b679-96b93da7383b",
|
||||
"type": "internet",
|
||||
"name": "Internet Resource",
|
||||
"gateway_groups": [{"name": "test", "id": "eb94482a-94f4-47cb-8127-14fb3afa5516"}],
|
||||
"not": "relevant",
|
||||
"some_other": [
|
||||
|
||||
@@ -80,6 +80,7 @@ pub fn internet_resource(
|
||||
) -> impl Strategy<Value = ResourceDescriptionInternet> {
|
||||
(resource_id(), sites, any::<bool>()).prop_map(move |(id, sites, can_be_disabled)| {
|
||||
ResourceDescriptionInternet {
|
||||
name: "Internet Resource".to_string(),
|
||||
id,
|
||||
sites,
|
||||
can_be_disabled: Some(can_be_disabled),
|
||||
|
||||
@@ -12,11 +12,10 @@ use connlib_shared::{
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use tauri::{SystemTray, SystemTrayHandle};
|
||||
use url::Url;
|
||||
|
||||
mod builder;
|
||||
|
||||
pub(crate) use builder::{copyable, item, Event, Item, Menu, Window};
|
||||
pub(crate) use builder::{item, Event, Menu, Window};
|
||||
|
||||
// Figma is the source of truth for the tray icons
|
||||
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=OGFabKWPx7PRUZmq-0>
|
||||
@@ -40,6 +39,8 @@ const DISCONNECT_AND_QUIT: &str = "Disconnect and quit Firezone";
|
||||
const DISABLE: &str = "Disable this resource";
|
||||
const ENABLE: &str = "Enable this resource";
|
||||
|
||||
pub(crate) const INTERNET_RESOURCE_DESCRIPTION: &str = "All network traffic";
|
||||
|
||||
pub(crate) fn loading() -> SystemTray {
|
||||
SystemTray::new()
|
||||
.with_icon(tauri::Icon::Raw(BUSY_ICON.into()))
|
||||
@@ -68,27 +69,25 @@ pub(crate) struct SignedIn<'a> {
|
||||
}
|
||||
|
||||
impl<'a> SignedIn<'a> {
|
||||
fn is_favorite(&self, res: &ResourceDescription) -> bool {
|
||||
self.favorite_resources.contains(&res.id())
|
||||
fn is_favorite(&self, resource: &ResourceId) -> bool {
|
||||
self.favorite_resources.contains(resource)
|
||||
}
|
||||
|
||||
fn add_favorite_toggle(&self, submenu: &mut Menu, resource: ResourceId) {
|
||||
if self.is_favorite(&resource) {
|
||||
submenu.add_item(item(Event::RemoveFavorite(resource), REMOVE_FAVORITE).selected());
|
||||
} else {
|
||||
submenu.add_item(item(Event::AddFavorite(resource), ADD_FAVORITE));
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the submenu that has the resource address, name, desc,
|
||||
/// sites online, etc.
|
||||
fn resource_submenu(&self, res: &ResourceDescription) -> Menu {
|
||||
let mut submenu = Menu::default();
|
||||
let mut submenu = Menu::default().resource_description(res);
|
||||
|
||||
submenu.add_item(resource_header(res));
|
||||
|
||||
let mut submenu = submenu
|
||||
.separator()
|
||||
.disabled("Resource")
|
||||
.copyable(res.name())
|
||||
.copyable(res.pastable().as_ref());
|
||||
|
||||
if self.is_favorite(res) {
|
||||
submenu.add_item(item(Event::RemoveFavorite(res.id()), REMOVE_FAVORITE).selected());
|
||||
} else {
|
||||
submenu.add_item(item(Event::AddFavorite(res.id()), ADD_FAVORITE));
|
||||
if !res.is_internet_resource() {
|
||||
self.add_favorite_toggle(&mut submenu, res.id());
|
||||
}
|
||||
|
||||
if res.can_be_disabled() {
|
||||
@@ -123,22 +122,6 @@ impl<'a> SignedIn<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_header(res: &ResourceDescription) -> Item {
|
||||
let Some(address_description) = res.address_description() else {
|
||||
return copyable(&res.pastable());
|
||||
};
|
||||
|
||||
if address_description.is_empty() {
|
||||
return copyable(&res.pastable());
|
||||
}
|
||||
|
||||
let Ok(url) = Url::parse(address_description) else {
|
||||
return copyable(address_description);
|
||||
};
|
||||
|
||||
item(Event::Url(url), format!("<{address_description}>"))
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(crate) enum Icon {
|
||||
/// Must be equivalent to the default app icon, since we assume this is set when we start
|
||||
@@ -247,7 +230,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu {
|
||||
// Always show Resources in the original order
|
||||
for res in resources
|
||||
.iter()
|
||||
.filter(|res| favorite_resources.contains(&res.id()))
|
||||
.filter(|res| favorite_resources.contains(&res.id()) || res.is_internet_resource())
|
||||
{
|
||||
menu = menu.add_submenu(res.name(), signed_in.resource_submenu(res));
|
||||
}
|
||||
@@ -266,7 +249,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu {
|
||||
// Always show Resources in the original order
|
||||
for res in resources
|
||||
.iter()
|
||||
.filter(|res| !favorite_resources.contains(&res.id()))
|
||||
.filter(|res| !favorite_resources.contains(&res.id()) && !res.is_internet_resource())
|
||||
{
|
||||
submenu = submenu.add_submenu(res.name(), signed_in.resource_submenu(res));
|
||||
}
|
||||
@@ -449,17 +432,7 @@ mod tests {
|
||||
.add_submenu(
|
||||
"Internet Resource",
|
||||
Menu::default()
|
||||
.copyable("")
|
||||
.separator()
|
||||
.disabled("Resource")
|
||||
.copyable("Internet Resource")
|
||||
.copyable("")
|
||||
.item(
|
||||
Event::AddFavorite(
|
||||
ResourceId::from_str("1106047c-cd5d-4151-b679-96b93da7383b").unwrap(),
|
||||
),
|
||||
ADD_FAVORITE,
|
||||
)
|
||||
.disabled(INTERNET_RESOURCE_DESCRIPTION)
|
||||
.separator()
|
||||
.disabled("Site")
|
||||
.copyable("test")
|
||||
@@ -510,48 +483,37 @@ mod tests {
|
||||
.copyable("test")
|
||||
.copyable(GATEWAY_CONNECTED),
|
||||
)
|
||||
.add_submenu(
|
||||
"Internet Resource",
|
||||
Menu::default()
|
||||
.disabled(INTERNET_RESOURCE_DESCRIPTION)
|
||||
.separator()
|
||||
.disabled("Site")
|
||||
.copyable("test")
|
||||
.copyable(ALL_GATEWAYS_OFFLINE),
|
||||
)
|
||||
.separator()
|
||||
.add_submenu(
|
||||
OTHER_RESOURCES,
|
||||
Menu::default()
|
||||
.add_submenu(
|
||||
"172.172.0.0/16",
|
||||
Menu::default()
|
||||
.copyable("cidr resource")
|
||||
.separator()
|
||||
.disabled("Resource")
|
||||
.copyable("172.172.0.0/16")
|
||||
.copyable("172.172.0.0/16")
|
||||
.item(
|
||||
Event::AddFavorite(ResourceId::from_str(
|
||||
"73037362-715d-4a83-a749-f18eadd970e6",
|
||||
)?),
|
||||
ADD_FAVORITE,
|
||||
)
|
||||
.separator()
|
||||
.disabled("Site")
|
||||
.copyable("test")
|
||||
.copyable(NO_ACTIVITY),
|
||||
)
|
||||
.add_submenu(
|
||||
"Internet Resource",
|
||||
Menu::default()
|
||||
.copyable("")
|
||||
.separator()
|
||||
.disabled("Resource")
|
||||
.copyable("Internet Resource")
|
||||
.copyable("")
|
||||
.item(
|
||||
Event::AddFavorite(ResourceId::from_str(
|
||||
"1106047c-cd5d-4151-b679-96b93da7383b",
|
||||
)?),
|
||||
ADD_FAVORITE,
|
||||
)
|
||||
.separator()
|
||||
.disabled("Site")
|
||||
.copyable("test")
|
||||
.copyable(ALL_GATEWAYS_OFFLINE),
|
||||
),
|
||||
Menu::default().add_submenu(
|
||||
"172.172.0.0/16",
|
||||
Menu::default()
|
||||
.copyable("cidr resource")
|
||||
.separator()
|
||||
.disabled("Resource")
|
||||
.copyable("172.172.0.0/16")
|
||||
.copyable("172.172.0.0/16")
|
||||
.item(
|
||||
Event::AddFavorite(ResourceId::from_str(
|
||||
"73037362-715d-4a83-a749-f18eadd970e6",
|
||||
)?),
|
||||
ADD_FAVORITE,
|
||||
)
|
||||
.separator()
|
||||
.disabled("Site")
|
||||
.copyable("test")
|
||||
.copyable(NO_ACTIVITY),
|
||||
),
|
||||
)
|
||||
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
|
||||
@@ -623,17 +585,7 @@ mod tests {
|
||||
.add_submenu(
|
||||
"Internet Resource",
|
||||
Menu::default()
|
||||
.copyable("")
|
||||
.separator()
|
||||
.disabled("Resource")
|
||||
.copyable("Internet Resource")
|
||||
.copyable("")
|
||||
.item(
|
||||
Event::AddFavorite(ResourceId::from_str(
|
||||
"1106047c-cd5d-4151-b679-96b93da7383b",
|
||||
)?),
|
||||
ADD_FAVORITE,
|
||||
)
|
||||
.disabled(INTERNET_RESOURCE_DESCRIPTION)
|
||||
.separator()
|
||||
.disabled("Site")
|
||||
.copyable("test")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! An abstraction over Tauri's system tray menu structs, that implements `PartialEq` for unit testing
|
||||
|
||||
use connlib_shared::messages::ResourceId;
|
||||
use connlib_shared::{callbacks::ResourceDescription, messages::ResourceId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use super::INTERNET_RESOURCE_DESCRIPTION;
|
||||
|
||||
/// A menu that can either be assigned to the system tray directly or used as a submenu in another menu.
|
||||
///
|
||||
/// Equivalent to `tauri::SystemTrayMenu`
|
||||
@@ -75,6 +77,22 @@ pub(crate) enum Window {
|
||||
Settings,
|
||||
}
|
||||
|
||||
fn resource_header(res: &ResourceDescription) -> Item {
|
||||
let Some(address_description) = res.address_description() else {
|
||||
return copyable(&res.pastable());
|
||||
};
|
||||
|
||||
if address_description.is_empty() {
|
||||
return copyable(&res.pastable());
|
||||
}
|
||||
|
||||
let Ok(url) = Url::parse(address_description) else {
|
||||
return copyable(address_description);
|
||||
};
|
||||
|
||||
item(Event::Url(url), format!("<{address_description}>"))
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
/// Appends things that always show, like About, Settings, Help, Quit, etc.
|
||||
pub(crate) fn add_bottom_section(self, quit_text: &str) -> Self {
|
||||
@@ -154,6 +172,26 @@ impl Menu {
|
||||
self.add_separator();
|
||||
self
|
||||
}
|
||||
|
||||
fn internet_resource(self) -> Self {
|
||||
self.disabled(INTERNET_RESOURCE_DESCRIPTION)
|
||||
}
|
||||
|
||||
fn resource_body(self, resource: &ResourceDescription) -> Self {
|
||||
self.separator()
|
||||
.disabled("Resource")
|
||||
.copyable(resource.name())
|
||||
.copyable(resource.pastable().as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn resource_description(mut self, resource: &ResourceDescription) -> Self {
|
||||
if resource.is_internet_resource() {
|
||||
self.internet_resource()
|
||||
} else {
|
||||
self.add_item(resource_header(resource));
|
||||
self.resource_body(resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
|
||||
@@ -25,14 +25,14 @@ public enum ResourceList {
|
||||
public struct Resource: Decodable, Identifiable, Equatable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
public var address: String
|
||||
public var address: String?
|
||||
public var addressDescription: String?
|
||||
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, canBeDisabled: Bool) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
@@ -42,6 +42,10 @@ public struct Resource: Decodable, Identifiable, Equatable {
|
||||
self.type = type
|
||||
self.canBeDisabled = canBeDisabled
|
||||
}
|
||||
|
||||
public func isInternetResource() -> Bool {
|
||||
self.type == ResourceType.internet
|
||||
}
|
||||
}
|
||||
|
||||
public enum ResourceStatus: String, Decodable {
|
||||
|
||||
@@ -418,12 +418,12 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
// If we have no favorites, then everything is a favorite
|
||||
let hasAnyFavorites = newResources.contains { model.favorites.contains($0.id) }
|
||||
let newFavorites = if (hasAnyFavorites) {
|
||||
newResources.filter { model.favorites.contains($0.id) }
|
||||
newResources.filter { model.favorites.contains($0.id) || $0.isInternetResource() }
|
||||
} else {
|
||||
newResources
|
||||
}
|
||||
let newOthers: [Resource] = if hasAnyFavorites {
|
||||
newResources.filter { !model.favorites.contains($0.id) }
|
||||
newResources.filter { !model.favorites.contains($0.id) && !$0.isInternetResource() }
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
@@ -511,20 +511,11 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
model.isResourceEnabled(id) ? "Disable this resource" : "Enable this resource"
|
||||
}
|
||||
|
||||
private func createSubMenu(resource: Resource) -> NSMenu {
|
||||
private func nonInternetResourceHeader(resource: Resource) -> NSMenu {
|
||||
let subMenu = NSMenu()
|
||||
let resourceAddressDescriptionItem = NSMenuItem()
|
||||
let resourceSectionItem = NSMenuItem()
|
||||
let resourceNameItem = NSMenuItem()
|
||||
let resourceAddressItem = NSMenuItem()
|
||||
let siteSectionItem = NSMenuItem()
|
||||
let siteNameItem = NSMenuItem()
|
||||
let siteStatusItem = NSMenuItem()
|
||||
let toggleFavoriteItem = NSMenuItem()
|
||||
let enableToggle = NSMenuItem()
|
||||
|
||||
|
||||
// AddressDescription first -- will be most common action
|
||||
let resourceAddressDescriptionItem = NSMenuItem()
|
||||
if let addressDescription = resource.addressDescription {
|
||||
resourceAddressDescriptionItem.title = addressDescription
|
||||
|
||||
@@ -544,7 +535,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
} else {
|
||||
// Show Address first if addressDescription is missing
|
||||
resourceAddressDescriptionItem.title = resource.address
|
||||
resourceAddressDescriptionItem.title = resource.address! // Address is none only for non-internet resource
|
||||
resourceAddressDescriptionItem.action = #selector(resourceValueTapped(_:))
|
||||
}
|
||||
resourceAddressDescriptionItem.isEnabled = true
|
||||
@@ -553,11 +544,13 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
|
||||
subMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
let resourceSectionItem = NSMenuItem()
|
||||
resourceSectionItem.title = "Resource"
|
||||
resourceSectionItem.isEnabled = false
|
||||
subMenu.addItem(resourceSectionItem)
|
||||
|
||||
// Resource name
|
||||
let resourceNameItem = NSMenuItem()
|
||||
resourceNameItem.action = #selector(resourceValueTapped(_:))
|
||||
resourceNameItem.title = resource.name
|
||||
resourceNameItem.toolTip = "Resource name (click to copy)"
|
||||
@@ -566,13 +559,16 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
subMenu.addItem(resourceNameItem)
|
||||
|
||||
// Resource address
|
||||
let resourceAddressItem = NSMenuItem()
|
||||
resourceAddressItem.action = #selector(resourceValueTapped(_:))
|
||||
resourceAddressItem.title = resource.address
|
||||
resourceAddressItem.title = resource.address!
|
||||
resourceAddressItem.toolTip = "Resource address (click to copy)"
|
||||
resourceAddressItem.isEnabled = true
|
||||
resourceAddressItem.target = self
|
||||
subMenu.addItem(resourceAddressItem)
|
||||
|
||||
let toggleFavoriteItem = NSMenuItem()
|
||||
|
||||
if model.favorites.contains(resource.id) {
|
||||
toggleFavoriteItem.action = #selector(removeFavoriteTapped(_:))
|
||||
toggleFavoriteItem.title = "Remove from favorites"
|
||||
@@ -587,6 +583,36 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
toggleFavoriteItem.target = self
|
||||
subMenu.addItem(toggleFavoriteItem)
|
||||
|
||||
return subMenu
|
||||
}
|
||||
|
||||
private func internetResourceHeader(resource: Resource) -> NSMenu {
|
||||
let subMenu = NSMenu()
|
||||
let description = NSMenuItem()
|
||||
|
||||
description.title = "All network traffic"
|
||||
description.isEnabled = false
|
||||
|
||||
subMenu.addItem(description)
|
||||
|
||||
return subMenu
|
||||
}
|
||||
|
||||
private func resourceHeader(resource: Resource) -> NSMenu {
|
||||
if resource.isInternetResource() {
|
||||
internetResourceHeader(resource: resource)
|
||||
} else {
|
||||
nonInternetResourceHeader(resource: resource)
|
||||
}
|
||||
}
|
||||
|
||||
private func createSubMenu(resource: Resource) -> NSMenu {
|
||||
let siteSectionItem = NSMenuItem()
|
||||
let siteNameItem = NSMenuItem()
|
||||
let siteStatusItem = NSMenuItem()
|
||||
let enableToggle = NSMenuItem()
|
||||
|
||||
let subMenu = resourceHeader(resource: resource)
|
||||
|
||||
// Resource enable / disable toggle
|
||||
if resource.canBeDisabled {
|
||||
|
||||
@@ -39,18 +39,18 @@ struct ResourceView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
if let url = URL(string: resource.addressDescription ?? resource.address),
|
||||
if let url = URL(string: resource.addressDescription ?? resource.address!),
|
||||
let _ = url.host {
|
||||
Button(action: {
|
||||
openURL(url)
|
||||
}) {
|
||||
Text(resource.addressDescription ?? resource.address)
|
||||
Text(resource.addressDescription ?? resource.address!)
|
||||
.foregroundColor(.blue)
|
||||
.underline()
|
||||
.font(.system(size: 16))
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
copyToClipboard(resource.addressDescription ?? resource.address)
|
||||
copyToClipboard(resource.addressDescription ?? resource.address!)
|
||||
}) {
|
||||
Text("Copy address")
|
||||
Image(systemName: "doc.on.doc")
|
||||
@@ -58,10 +58,10 @@ struct ResourceView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(resource.addressDescription ?? resource.address)
|
||||
Text(resource.addressDescription ?? resource.address!)
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
copyToClipboard(resource.addressDescription ?? resource.address)
|
||||
copyToClipboard(resource.addressDescription ?? resource.address!)
|
||||
}) {
|
||||
Text("Copy address")
|
||||
Image(systemName: "doc.on.doc")
|
||||
|
||||
@@ -130,22 +130,35 @@ struct ResourceSection: View {
|
||||
var body: some View {
|
||||
ForEach(resources) { resource in
|
||||
HStack {
|
||||
NavigationLink { ResourceView(model: model, resource: resource) }
|
||||
label: {
|
||||
HStack {
|
||||
Text(resource.name)
|
||||
if resource.canBeDisabled {
|
||||
Spacer()
|
||||
Toggle("Enabled", isOn: Binding<Bool>(
|
||||
get: { model.isResourceEnabled(resource.id) },
|
||||
set: { newValue in
|
||||
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
|
||||
}
|
||||
)).labelsHidden()
|
||||
if !resource.isInternetResource() {
|
||||
NavigationLink { ResourceView(model: model, resource: resource) }
|
||||
label: {
|
||||
ResourceLabel(resource: resource, model: model )
|
||||
}
|
||||
} else {
|
||||
ResourceLabel(resource: resource, model: model)
|
||||
}
|
||||
}
|
||||
.navigationTitle("All Resources")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ResourceLabel: View {
|
||||
let resource: Resource
|
||||
@ObservedObject var model: SessionViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(resource.name)
|
||||
if resource.canBeDisabled {
|
||||
Spacer()
|
||||
Toggle("Enabled", isOn: Binding<Bool>(
|
||||
get: { model.isResourceEnabled(resource.id) },
|
||||
set: { newValue in
|
||||
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
|
||||
}
|
||||
)).labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user