diff --git a/Cargo.lock b/Cargo.lock index 301d1f3..f61fe95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7768,7 +7768,7 @@ dependencies = [ [[package]] name = "upvpn-packages" -version = "0.4.0" +version = "0.5.0" [[package]] name = "upvpn-server" diff --git a/upvpn-android/app/build.gradle.kts b/upvpn-android/app/build.gradle.kts index 420420a..240823f 100644 --- a/upvpn-android/app/build.gradle.kts +++ b/upvpn-android/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "app.upvpn.upvpn" minSdk = 24 targetSdk = 34 - versionCode = 4 - versionName = "u1-beta" + versionCode = 5 + versionName = "u2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/VPNRepository.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/VPNRepository.kt index 6ee9c37..c960ed4 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/VPNRepository.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/VPNRepository.kt @@ -43,8 +43,6 @@ class DefaultVPNRepository( private val tag = "DefaultVPNRepository" - private var locations: List = listOf() - private fun createDevice(): Device { val keyPair = KeyPair() val device = Device( @@ -97,46 +95,52 @@ class DefaultVPNRepository( return addDeviceResponse.map { it.token } } + private suspend fun postSignOutCleanup() { + vpnDatabase.withTransaction { + val device = vpnDatabase.deviceDao().getDevice() + val user = vpnDatabase.userDao().getUser() + device?.let { vpnDatabase.deviceDao().delete(it) } + user?.let { vpnDatabase.userDao().delete(it) } + } + } + override suspend fun signOut(): Result { val signedOut = vpnApiService.signOut().toResult().mapError { e -> e.message } - signedOut.onSuccess { - vpnDatabase.withTransaction { - val device = vpnDatabase.deviceDao().getDevice() - val user = vpnDatabase.userDao().getUser() - device?.let { vpnDatabase.deviceDao().delete(it) } - user?.let { vpnDatabase.userDao().delete(it) } + return signedOut.fold( + success = { + postSignOutCleanup() + Ok(Unit) + }, + failure = { + if (it == "unauthorized") { + postSignOutCleanup() + Ok(Unit) + } else { + Err(it) + } } - } - - return signedOut + ) } override suspend fun getLocations(): Result, String> { - var result = if (locations.isEmpty()) { - val fromDatabase = vpnDatabase.locationDao().getLocations() - if (fromDatabase.isEmpty()) { - val apiResult = vpnApiService.getLocations().toResult().mapError { e -> e.message } - - apiResult.fold( - success = { newLocations -> - Log.i(tag, "received ${newLocations.size} locations from API") - locations = newLocations - val dbLocations = locations.map { it.toDbLocation() } - vpnDatabase.locationDao().insert(dbLocations) - Ok(locations) - }, - failure = { error -> - Log.i(tag, "failed to get locations from API $error") - Err(error) - } - ) - } else { - Ok(fromDatabase.map { it.toModelLocation() }) + + val apiResult = vpnApiService.getLocations().toResult().mapError { e -> e.message } + + var result = apiResult.fold( + success = { newLocations -> + Log.i(tag, "received ${newLocations.size} locations from API") + val newLocationCodes = newLocations.map { it.code } + vpnDatabase.locationDao().deleteNotIn(newLocationCodes); + val dbLocations = newLocations.map { it.toDbLocation() } + vpnDatabase.locationDao().insert(dbLocations) + Ok(newLocations) + }, + failure = { error -> + Log.i(tag, "failed to get locations from API $error") + Err(error) } - } else { - Ok(locations) - } + ) return result } diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/db/LocationDao.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/db/LocationDao.kt index c99adf0..91306e1 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/db/LocationDao.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/data/db/LocationDao.kt @@ -12,9 +12,12 @@ interface LocationDao { @Query("SELECT * FROM location") suspend fun getLocations(): List - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(locations: List) + @Query("DELETE FROM location WHERE code NOT IN (:notInLocationCodes)") + suspend fun deleteNotIn(notInLocationCodes: List); + @Query("UPDATE location SET lastAccess = :lastAccess WHERE code = :code") suspend fun updateLastAccess(code: String, lastAccess: Long) diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/LocationExtension.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/LocationExtension.kt index 9b6df19..9f40ede 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/LocationExtension.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/LocationExtension.kt @@ -1,5 +1,7 @@ package app.upvpn.upvpn.model +import androidx.compose.ui.graphics.Color + fun List.toCountries(): List = this.sortedWith(compareByDescending { it.country }.thenBy { it.city }) .groupBy { it.country } @@ -46,8 +48,36 @@ fun Location.displayText(): String { else -> "${city.uppercase()}, ${stateCode.uppercase()}" } } + else -> { city.uppercase() } } } + +val LOCATION_WARM_COLOR = Color(22, 163, 74, 255) +val LOCATION_COLD_COLOR = Color(56, 189, 248, 255) + +fun Location.warmOrColdColor(): Color { + return when (this.estimate) { + null -> Color.Unspecified + else -> { + when (this.estimate <= 10) { + true -> LOCATION_WARM_COLOR + else -> LOCATION_COLD_COLOR + } + } + } +} + +fun Location.warmOrColdDescription(): String { + return when (this.estimate) { + null -> "" + else -> { + when (this.estimate <= 10) { + true -> "Warm" + else -> "Cold" + } + } + } +} diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/Types.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/Types.kt index acb3b42..75771f7 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/Types.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/model/Types.kt @@ -66,7 +66,8 @@ data class Location( val city: String, val cityCode: String, val state: String? = null, - val stateCode: String? = null + val stateCode: String? = null, + val estimate: Short? = null ) : Parcelable @Serializable diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/VPNApp.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/VPNApp.kt index 3d0f827..1e45518 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/VPNApp.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/VPNApp.kt @@ -88,13 +88,21 @@ fun VPNApp( val vpnNotifications = homeVM.vpnNotificationState.collectAsStateWithLifecycle() LaunchedEffect(key1 = vpnNotifications.value) { + var unauthorized = false; for (notification in vpnNotifications.value) { showSnackBar(notification.msg) homeVM.ackVpnNotification(notification) + if (notification.msg == "unauthorized") { + unauthorized = true; + } + } + + if (unauthorized) { + (vm::onSignOutClick)() } } - if(BuildConfig.DEBUG) { + if (BuildConfig.DEBUG) { Log.d("VPNApp", "CURRENT SCREEN: $currentVPNScreen") } diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/CountryIcon.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/CountryIcon.kt index f71bd4e..caf9cb7 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/CountryIcon.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/CountryIcon.kt @@ -1,5 +1,6 @@ package app.upvpn.upvpn.ui.components +import androidx.compose.foundation.border import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationComponent.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationComponent.kt index 40431c0..a975702 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationComponent.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationComponent.kt @@ -6,8 +6,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text @@ -17,6 +21,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import app.upvpn.upvpn.model.LOCATION_COLD_COLOR +import app.upvpn.upvpn.model.LOCATION_WARM_COLOR import app.upvpn.upvpn.model.Location @@ -45,6 +51,23 @@ fun LocationComponent( Text(text = location.city, modifier = Modifier.weight(1f)) + location?.estimate?.let { + if (it <= 10) { + Icon( + imageVector = Icons.Rounded.Circle, + contentDescription = "Warm", + modifier = Modifier.size(15.dp), + tint = LOCATION_WARM_COLOR + ) + } else { + Icon( + imageVector = Icons.Rounded.Circle, contentDescription = "Cold", + modifier = Modifier.size(15.dp), + tint = LOCATION_COLD_COLOR + ) + } + } + RadioButton( enabled = isVpnSessionActivityInProgress.not(), selected = isSelectedLocation(location), diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationSelector.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationSelector.kt index 7c7794c..bdf3fd7 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationSelector.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/components/LocationSelector.kt @@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -24,9 +26,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.upvpn.upvpn.model.Location import app.upvpn.upvpn.model.displayText +import app.upvpn.upvpn.model.warmOrColdColor +import app.upvpn.upvpn.model.warmOrColdDescription import app.upvpn.upvpn.ui.state.LocationState import app.upvpn.upvpn.ui.state.VpnUiState -import app.upvpn.upvpn.ui.state.locationSelectorEnabled +import app.upvpn.upvpn.ui.state.isVpnSessionActivityInProgress @Composable fun LocationSelector( @@ -54,11 +58,15 @@ fun LocationSelector( val found = locationState.locations.firstOrNull { it.city.contains("ashburn") } if (found != null) { - Triple(found.displayText(), Icons.Rounded.ChevronRight, openLocationScreen) + Triple( + found.displayText(), + Icons.Rounded.Circle, + openLocationScreen + ) } else { Triple( locationState.locations.first().displayText(), - Icons.Rounded.ChevronRight, + Icons.Rounded.Circle, openLocationScreen ) } @@ -66,7 +74,7 @@ fun LocationSelector( } else { Triple( selectedLocation.displayText(), - Icons.Rounded.ChevronRight, + Icons.Rounded.Circle, openLocationScreen ) } @@ -83,7 +91,6 @@ fun LocationSelector( disabledContainerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface ), - enabled = vpnUiState.locationSelectorEnabled() && locationState.locationSelectorEnabled(), modifier = modifier .fillMaxWidth(0.8f) .height(50.dp) @@ -98,16 +105,28 @@ fun LocationSelector( .fillMaxHeight(0.6f) .aspectRatio(1f) ) - } - if (displayText.contains("No Locations").not()) { - CountryIcon(countryCode = selectedLocation?.countryCode ?: "US") - } - Text(text = displayText, overflow = TextOverflow.Visible) - if (isLoading.not()) { - Icon( - imageVector = icon, - contentDescription = "Arrow" - ) + } else { + if (displayText.contains("No Locations")) { + Text(text = displayText, overflow = TextOverflow.Visible) + Icon(imageVector = icon, contentDescription = "Reload") + } else { + CountryIcon(countryCode = selectedLocation?.countryCode ?: "US") + Text(text = displayText, overflow = TextOverflow.Visible) + if (vpnUiState.isVpnSessionActivityInProgress().not()) { + Icon( + imageVector = icon, + contentDescription = selectedLocation?.warmOrColdDescription() + ?: "Warm or Cold", + modifier = Modifier.size(15.dp), + tint = selectedLocation?.warmOrColdColor() ?: Color.Transparent + ) + } else { + Icon( + imageVector = Icons.Rounded.ChevronRight, + contentDescription = "Arrow" + ) + } + } } } } diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/state/Extentions.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/state/Extentions.kt index d4a852c..380eef9 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/state/Extentions.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/state/Extentions.kt @@ -39,19 +39,6 @@ fun VpnUiState.vpnDisplayText(): String { fun VpnUiState.isVpnSessionActivityInProgress(): Boolean = (this is VpnUiState.Disconnected).not() -fun VpnUiState.locationSelectorEnabled(): Boolean { - return when (this) { - is VpnUiState.Disconnected -> true - else -> false - } -} - -fun LocationState.locationSelectorEnabled(): Boolean { - return when (this) { - is LocationState.Loading -> false - else -> true - } -} fun VpnUiState.switchEnabled(): Boolean { return when (this) { diff --git a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/viewmodels/LocationViewModel.kt b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/viewmodels/LocationViewModel.kt index b686db6..4e69570 100644 --- a/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/viewmodels/LocationViewModel.kt +++ b/upvpn-android/app/src/main/kotlin/app/upvpn/upvpn/ui/viewmodels/LocationViewModel.kt @@ -10,10 +10,12 @@ import app.upvpn.upvpn.ui.state.LocationUiState import com.github.michaelbull.result.fold import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -28,8 +30,25 @@ class LocationViewModel( private val _uiState = MutableStateFlow(LocationUiState()) val uiState: StateFlow = _uiState.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) val recentLocations = vpnRepository.getRecentLocations(5) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), listOf()) + .mapLatest { list -> + // map to get estimates from location + when (_uiState.value.locationState) { + is LocationState.Locations -> { + val locations = + (_uiState.value.locationState as LocationState.Locations).locations; + + list.map { location -> + val estimate = locations.find { it.code == location.code } + location.copy(estimate = estimate?.estimate) + } + } + + else -> list + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), listOf()) private suspend fun getLocations() { _uiState.update { value -> value.copy(locationState = LocationState.Loading) } diff --git a/upvpn-controller/proto/upvpn-controller.proto b/upvpn-controller/proto/upvpn-controller.proto index ddc6dd5..beb5e20 100644 --- a/upvpn-controller/proto/upvpn-controller.proto +++ b/upvpn-controller/proto/upvpn-controller.proto @@ -116,6 +116,7 @@ message Location { string city_code = 5; optional string state = 6; optional string state_code = 7; + optional uint32 estimate = 8; } message DaemonEvent { diff --git a/upvpn-controller/src/conversions/location.rs b/upvpn-controller/src/conversions/location.rs index a2d4e27..e902afd 100644 --- a/upvpn-controller/src/conversions/location.rs +++ b/upvpn-controller/src/conversions/location.rs @@ -8,6 +8,7 @@ impl From for crate::proto::Location { city_code: value.city_code, state: value.state, state_code: value.state_code, + estimate: value.estimate, } } } @@ -22,6 +23,7 @@ impl From for upvpn_types::location::Location { city_code: value.city_code, state: value.state, state_code: value.state_code, + estimate: value.estimate, } } } diff --git a/upvpn-daemon/src/device/handler.rs b/upvpn-daemon/src/device/handler.rs index 00dbdb9..35c4ef5 100644 --- a/upvpn-daemon/src/device/handler.rs +++ b/upvpn-daemon/src/device/handler.rs @@ -206,7 +206,14 @@ impl DeviceService { // make API call to invalidate token let mut server_api = ServerApi::new(self.token_storage.clone()).await?; - server_api.sign_out().await?; + let status = server_api.sign_out().await; + + // Handle any error other than unauthenticated + if let Err(status) = status { + if status.code() != tonic::Code::Unauthenticated { + return Err(DeviceError::Server(status)); + } + } // reinitialize device self.device_storage.reinitialize("sign out").await?; @@ -231,7 +238,7 @@ impl DeviceService { async fn handle_is_authenticated(&self, tx: ResponseTx) { let token = self.token.clone(); tokio::spawn(async move { - // todo: validate token from backend; if invalid purge from DB and memory + // If token is expired UI will get unauthenticated and it will drive the sign out and sign in again. Self::oneshot_send(tx, Ok(token.is_some()), "handle_is_authenticated") }); } diff --git a/upvpn-entity/src/conversions/location.rs b/upvpn-entity/src/conversions/location.rs index a9f551d..db6b0ac 100644 --- a/upvpn-entity/src/conversions/location.rs +++ b/upvpn-entity/src/conversions/location.rs @@ -8,6 +8,7 @@ impl From for upvpn_types::location::Location { city_code: value.location_city_code, state: value.location_state, state_code: value.location_state_code, + estimate: None, } } } @@ -22,6 +23,7 @@ impl From for upvpn_types::location::Location { city_code: value.city_code, state: value.state, state_code: value.state_code, + estimate: None, } } } diff --git a/upvpn-packages/Cargo.toml b/upvpn-packages/Cargo.toml index 7646139..75b99bd 100644 --- a/upvpn-packages/Cargo.toml +++ b/upvpn-packages/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "upvpn-packages" -version = "0.4.0" # actual version +version = "0.5.0" # actual version edition = "2021" publish = false license = "GPL-3.0" diff --git a/upvpn-server/proto/upvpn-server.proto b/upvpn-server/proto/upvpn-server.proto index 75beadd..0fb8b42 100644 --- a/upvpn-server/proto/upvpn-server.proto +++ b/upvpn-server/proto/upvpn-server.proto @@ -19,6 +19,7 @@ message Location { string city_code = 5; optional string state = 6; optional string state_code = 7; + optional uint32 estimate = 8; } /// VPN Sessions diff --git a/upvpn-server/src/conversions/location.rs b/upvpn-server/src/conversions/location.rs index a6a1b57..54a82f6 100644 --- a/upvpn-server/src/conversions/location.rs +++ b/upvpn-server/src/conversions/location.rs @@ -8,6 +8,7 @@ impl From for upvpn_types::location::Location { city_code: value.city_code, state: value.state, state_code: value.state_code, + estimate: value.estimate, } } } diff --git a/upvpn-types/src/location.rs b/upvpn-types/src/location.rs index d30f619..7c38703 100644 --- a/upvpn-types/src/location.rs +++ b/upvpn-types/src/location.rs @@ -15,6 +15,8 @@ pub struct Location { pub state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub state_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub estimate: Option, } impl Display for Location { diff --git a/upvpn-ui/package-lock.json b/upvpn-ui/package-lock.json index 65e4143..97ad969 100644 --- a/upvpn-ui/package-lock.json +++ b/upvpn-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "upvpn-ui", "version": "0.0.0", "dependencies": { + "@tailwindcss/typography": "^0.5.10", "@tauri-apps/api": "^1.2.0", "daisyui": "^2.51.5", "luxon": "^3.3.0", @@ -1001,6 +1002,32 @@ "@svgr/core": "^6.0.0" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz", + "integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tauri-apps/api": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.2.0.tgz", @@ -2013,6 +2040,21 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3414,6 +3456,28 @@ "svg-parser": "^2.0.4" } }, + "@tailwindcss/typography": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz", + "integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==", + "requires": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + } + } + }, "@tauri-apps/api": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.2.0.tgz", @@ -4100,6 +4164,21 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/upvpn-ui/package.json b/upvpn-ui/package.json index 626db89..a51b1e3 100644 --- a/upvpn-ui/package.json +++ b/upvpn-ui/package.json @@ -10,6 +10,7 @@ "tauri": "tauri" }, "dependencies": { + "@tailwindcss/typography": "^0.5.10", "@tauri-apps/api": "^1.2.0", "daisyui": "^2.51.5", "luxon": "^3.3.0", diff --git a/upvpn-ui/src-tauri/src/commands/daemon_online.rs b/upvpn-ui/src-tauri/src/commands/daemon_online.rs new file mode 100644 index 0000000..1a081a2 --- /dev/null +++ b/upvpn-ui/src-tauri/src/commands/daemon_online.rs @@ -0,0 +1,10 @@ +use crate::error::Error; + +#[tauri::command] +pub async fn is_daemon_online() -> Result<(), Error> { + let _client = upvpn_controller::new_grpc_client() + .await + .map_err(|_| Error::DaemonIsOffline)?; + + Ok(()) +} diff --git a/upvpn-ui/src-tauri/src/commands/mod.rs b/upvpn-ui/src-tauri/src/commands/mod.rs index d9247c3..49ef1ce 100644 --- a/upvpn-ui/src-tauri/src/commands/mod.rs +++ b/upvpn-ui/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod auth; +pub(crate) mod daemon_online; pub(crate) mod desktop_notification; pub(crate) mod file_ops; pub(crate) mod location; diff --git a/upvpn-ui/src-tauri/src/main.rs b/upvpn-ui/src-tauri/src/main.rs index e02c372..85c3a56 100644 --- a/upvpn-ui/src-tauri/src/main.rs +++ b/upvpn-ui/src-tauri/src/main.rs @@ -10,6 +10,7 @@ mod state; mod system_tray; use commands::auth::{is_signed_in, sign_in, sign_out}; +use commands::daemon_online::is_daemon_online; use commands::desktop_notification::send_desktop_notification; use commands::file_ops::{open_license, open_log_file}; use commands::location::{locations, recent_locations}; @@ -58,6 +59,7 @@ fn main() { builder .manage(AppState::default()) .invoke_handler(tauri::generate_handler![ + is_daemon_online, is_signed_in, sign_in, sign_out, diff --git a/upvpn-ui/src/components/City.tsx b/upvpn-ui/src/components/City.tsx index 4fc1991..d0de72b 100644 --- a/upvpn-ui/src/components/City.tsx +++ b/upvpn-ui/src/components/City.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react"; +import React, { useContext } from "react"; import { Location } from "../lib/types"; import ReactCountryFlag from "react-country-flag"; import LocationContext, { @@ -6,6 +6,7 @@ import LocationContext, { } from "../context/LocationContext"; import { toast } from "react-hot-toast"; import { handleEnterKey } from "../lib/util"; +import LocationWarmColdIcon from "./LocationWarmColdIcon"; type Props = { location: Location; @@ -43,13 +44,16 @@ function City({ location, enabled }: Props) { />
{location.city}
- {}} - disabled={enabled ? false : true} - /> +
+ + {}} + disabled={enabled ? false : true} + /> +
); } diff --git a/upvpn-ui/src/components/LocationSelector.tsx b/upvpn-ui/src/components/LocationSelector.tsx index 694ee46..12b8f2a 100644 --- a/upvpn-ui/src/components/LocationSelector.tsx +++ b/upvpn-ui/src/components/LocationSelector.tsx @@ -1,13 +1,13 @@ import React, { useContext } from "react"; import { useNavigate } from "react-router-dom"; import ReactCountryFlag from "react-country-flag"; -import { MdKeyboardArrowRight } from "react-icons/md"; import { IoReload } from "react-icons/io5"; import LocationContext, { LocationContextInterface, } from "../context/LocationContext"; import Spinner from "./Spinner"; import { defaultLocation } from "../lib/util"; +import LocationWarmColdIcon from "./LocationWarmColdIcon"; type Props = {}; @@ -70,7 +70,7 @@ function LocationSelector({}: Props) { }} />
{displayLocation}
- + ); } diff --git a/upvpn-ui/src/components/LocationWarmColdIcon.tsx b/upvpn-ui/src/components/LocationWarmColdIcon.tsx new file mode 100644 index 0000000..462e083 --- /dev/null +++ b/upvpn-ui/src/components/LocationWarmColdIcon.tsx @@ -0,0 +1,42 @@ +import React, { useContext } from "react"; +import { MdKeyboardArrowRight, MdCircle } from "react-icons/md"; +import { Location } from "../lib/types"; +import VpnStatusContext, { + VpnStatusContextInterface, +} from "../context/VpnStatusContext"; +import { isVpnInProgress } from "../lib/util"; + +type Props = { + location: Location; + arrow: boolean; +}; + +const LocationWarmColdIcon = ({ location, arrow }: Props) => { + const { vpnStatus } = useContext( + VpnStatusContext + ) as VpnStatusContextInterface; + + if (isVpnInProgress(vpnStatus)) { + if (arrow) { + return ; + } else { + <>; + } + } + + if (location.estimate === undefined) { + if (arrow) { + return ; + } else { + return <>; + } + } else { + if (location.estimate <= 10) { + return ; + } else { + return ; + } + } +}; + +export default LocationWarmColdIcon; diff --git a/upvpn-ui/src/components/Navbar.tsx b/upvpn-ui/src/components/Navbar.tsx index f885ae0..7761edb 100644 --- a/upvpn-ui/src/components/Navbar.tsx +++ b/upvpn-ui/src/components/Navbar.tsx @@ -1,7 +1,6 @@ import React from "react"; import { MdKeyboardArrowLeft } from "react-icons/md"; import { useNavigate } from "react-router-dom"; -import { KeyboardEvent } from "react"; import { handleEnterKey } from "../lib/util"; type Props = { diff --git a/upvpn-ui/src/components/Timer.tsx b/upvpn-ui/src/components/Timer.tsx index 123c461..19cca8f 100644 --- a/upvpn-ui/src/components/Timer.tsx +++ b/upvpn-ui/src/components/Timer.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { DateTime } from "luxon"; type Props = { diff --git a/upvpn-ui/src/context/VpnStatusContext.tsx b/upvpn-ui/src/context/VpnStatusContext.tsx index d59e2cc..9ae02dd 100644 --- a/upvpn-ui/src/context/VpnStatusContext.tsx +++ b/upvpn-ui/src/context/VpnStatusContext.tsx @@ -16,17 +16,7 @@ import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api"; import { info } from "tauri-plugin-log-api"; import { useNavigate } from "react-router-dom"; -import { handleError, send_desktop_notification } from "../lib/util"; -import { appWindow } from "@tauri-apps/api/window"; -import { TauriEvent } from "@tauri-apps/api/event"; -import { toast } from "react-hot-toast"; -import { DateTime } from "luxon"; -import { - isPermissionGranted, - requestPermission, - sendNotification, -} from "@tauri-apps/api/notification"; -import { type } from "@tauri-apps/api/os"; +import { handleError } from "../lib/util"; export interface ElapsedTime { days: string; diff --git a/upvpn-ui/src/hooks/useAuthStatus.ts b/upvpn-ui/src/hooks/useAuthStatus.ts index 3f5fe90..96d194a 100644 --- a/upvpn-ui/src/hooks/useAuthStatus.ts +++ b/upvpn-ui/src/hooks/useAuthStatus.ts @@ -12,13 +12,12 @@ function useAuthStatus() { const [daemonOffline, setDaemonOffline] = useState(false) const [signedIn, setSignedIn] = useState(false) - // periodically check for auth status as well as offline status + // periodically check for daemon offline useEffect(() => { const id = setInterval(() => { async function check() { try { - const isSignedIn = await invoke('is_signed_in') as boolean - setSignedIn(isSignedIn); + const _isOnline = await invoke('is_daemon_online'); setDaemonOffline(false); } catch (e) { if (isOffline(e as UiError)) { diff --git a/upvpn-ui/src/lib/types.ts b/upvpn-ui/src/lib/types.ts index 084efd5..a19890a 100644 --- a/upvpn-ui/src/lib/types.ts +++ b/upvpn-ui/src/lib/types.ts @@ -7,6 +7,7 @@ export interface Location { city_code: string; state?: string; state_code?: string; + estimate?: number; } export type VpnStatus = diff --git a/upvpn-ui/src/lib/util.ts b/upvpn-ui/src/lib/util.ts index 0342920..a01f80e 100644 --- a/upvpn-ui/src/lib/util.ts +++ b/upvpn-ui/src/lib/util.ts @@ -7,19 +7,30 @@ import { invoke } from "@tauri-apps/api"; import { isPermissionGranted, requestPermission } from "@tauri-apps/api/notification"; import { KeyboardEvent } from "react"; -export function getLocationFromVpnStatus(status: VpnStatus): Location | undefined { - switch (status.type) { - case "Accepted": - case "Connecting": - case "Disconnecting": - case "ServerRunning": - case "ServerReady": - return status.payload - case "Connected": - return status.payload[0] - default: - return undefined; +export function getLocationFromVpnStatus(status: VpnStatus, locations: Location[]): Location | undefined { + + const locationInner = () => { + switch (status.type) { + case "Accepted": + case "Connecting": + case "Disconnecting": + case "ServerRunning": + case "ServerReady": + return status.payload + case "Connected": + return status.payload[0] + default: + return undefined; + } + } + + var location = locationInner(); + const found = locations?.find((l) => l.code == location?.code) + if (found !== undefined && location !== undefined) { + location.estimate = found.estimate; } + + return location } export const isVpnInProgress = (vpnStatus: VpnStatus | undefined) => { @@ -59,21 +70,35 @@ export const isUnauthenticated = (error: UiError): boolean => { } -export const handleError = (error: UiError, navigate: NavigateFunction, toastWhenUnauthenticated: boolean = false) => { +export const handleError = (error: UiError, navigate: NavigateFunction, isSignInPage: boolean = false) => { switch (error.type) { case "DaemonIsOffline": navigate("/daemon-offline"); break; case "Grpc": - logError(error.message); - if (error.code === Code.Unauthenticated) { - if (toastWhenUnauthenticated) { - toast.error(error.message); - } - navigate("/sign-in"); + logError(`code: ${error.code}, type: ${error.type}, message: ${error.message}`); + if (isSignInPage) { + toast.error(error.message) } else { - logError(`${error.code} ${error.message}`); - toast.error(error.message); + // any other page if it receives unauthenticated then we should sign out and + // redirect to sign page but if sign out itself errors then we just toast.error + if (error.code == Code.Unauthenticated) { + // sign out + try { + const signOut = async () => { + await invoke("sign_out"); + } + + signOut(); + navigate("/sign-in"); + } catch (e) { + // nothing much to do, toast original error message + logError(`error ${e} occurred when handling sign out after unauthenticated`); + toast.error(error.message) + } + } else { + toast.error(error.message) + } } } } diff --git a/upvpn-ui/src/pages/Home.tsx b/upvpn-ui/src/pages/Home.tsx index 661a5bc..eb094fc 100644 --- a/upvpn-ui/src/pages/Home.tsx +++ b/upvpn-ui/src/pages/Home.tsx @@ -23,7 +23,6 @@ import NotificationContext, { } from "../context/NotificationContext"; import Timer from "../components/Timer"; import { DateTime } from "luxon"; -import { KeyboardEvent } from "react"; type Props = {}; @@ -77,7 +76,7 @@ function Home({}: Props) { useEffect(() => { if (vpnStatus !== undefined) { - const locationFromStatus = getLocationFromVpnStatus(vpnStatus); + const locationFromStatus = getLocationFromVpnStatus(vpnStatus, locations); if (locationFromStatus != undefined) { setLocation(locationFromStatus); } diff --git a/upvpn-ui/src/pages/Locations.tsx b/upvpn-ui/src/pages/Locations.tsx index 049b842..dc4a9ed 100644 --- a/upvpn-ui/src/pages/Locations.tsx +++ b/upvpn-ui/src/pages/Locations.tsx @@ -1,8 +1,5 @@ import { useContext, useEffect, useState } from "react"; import Layout from "../components/Layout"; -import { Location } from "../lib/types"; -import { invoke } from "@tauri-apps/api"; -import toast from "react-hot-toast"; import Spinner from "../components/Spinner"; import LocationList from "../components/LocationList"; import LocationContext, { @@ -11,7 +8,6 @@ import LocationContext, { import VpnStatusContext, { VpnStatusContextInterface, } from "../context/VpnStatusContext"; -import { MdKeyboardArrowLeft } from "react-icons/md"; import { useNavigate } from "react-router-dom"; import Navbar from "../components/Navbar"; diff --git a/upvpn-ui/src/pages/Settings.tsx b/upvpn-ui/src/pages/Settings.tsx index 7d61fdb..c06cd45 100644 --- a/upvpn-ui/src/pages/Settings.tsx +++ b/upvpn-ui/src/pages/Settings.tsx @@ -1,7 +1,5 @@ import React, { useContext, useEffect, useState } from "react"; import Layout from "../components/Layout"; -import { info } from "tauri-plugin-log-api"; -import { open } from "@tauri-apps/api/shell"; import { useNavigate } from "react-router"; import Spinner from "../components/Spinner"; import { invoke } from "@tauri-apps/api"; @@ -12,7 +10,6 @@ import { handleEnterKey, handleError, isVpnInProgress } from "../lib/util"; import { UiError } from "../lib/types"; import { toast } from "react-hot-toast"; import Navbar from "../components/Navbar"; -import { Link } from "react-router-dom"; import { MdOpenInNew } from "react-icons/md"; type Props = {}; diff --git a/upvpn-ui/src/pages/SignIn.tsx b/upvpn-ui/src/pages/SignIn.tsx index 5c0f060..e65e7fb 100644 --- a/upvpn-ui/src/pages/SignIn.tsx +++ b/upvpn-ui/src/pages/SignIn.tsx @@ -1,9 +1,6 @@ import React, { useEffect, useState } from "react"; -import { IoMdLogIn } from "react-icons/io"; -import { GiPineTree } from "react-icons/gi"; import { invoke } from "@tauri-apps/api"; import { useNavigate } from "react-router-dom"; -import toast from "react-hot-toast"; import Spinner from "../components/Spinner"; import { info } from "tauri-plugin-log-api"; import { handleError } from "../lib/util"; diff --git a/upvpn-ui/tailwind.config.cjs b/upvpn-ui/tailwind.config.cjs index c1bfd6a..f8e0b81 100644 --- a/upvpn-ui/tailwind.config.cjs +++ b/upvpn-ui/tailwind.config.cjs @@ -7,5 +7,5 @@ module.exports = { theme: { extend: {}, }, - plugins: [require("daisyui")], + plugins: [require("daisyui"), require('@tailwindcss/typography')], }