pull request to main (#6)

* Implemented Experimental <INCLUDE> split tunnelling support. (#5)

Implemented Experimental <INCLUDE> split tunnelling support.

This adds a prototype "include-mode" routing model for Android VPN configuration, addressing long-standing user requests for selectively tunneling only chosen applications.

When enabled, the VPN builder whitelists specific package names rather than excluding all others. This avoids the need to maintain long exclude lists and supports targeted routing (e.g., only browsers, music players, etc).

Details: Implemented a UI toggle button which turns on split tunneling, and which then presents the option to either include or exclude app packages in the VPNBuilder, without having to use MDM. *When Split tunnelling is disabled, the app excludes all packages in builtInDisallowedPackageNames by default, mirroring existing default functionality when no packages are manually added to the exclude list*
If Included or Excluded packages are included by MDM, the split tunneling toggle is vacuously turned on -- and user include/exclude lists are ignored.

Limitations: DNS resolver traffic can fail under include-mode when Tailscale DNS is active (different results observed across 3 devices). Disabling Tailscale DNS appears to mitigate the issue, but the underlying resolver interaction remains open for analysis. Feedback, testing results, and alternative approaches from developers or knowledgeable users are welcome and appreciated.

This commit is intended as a discussion basis / RFC for upstream consideration and iteration :)

---------

Signed-off-by: Danial Ramzan <danialramzan@gmail.com>

* Implemented search bar in split-tunneling page.
Signed-off-by: Danial Ramzan <danialramzan@gmail.com>

---------

Signed-off-by: Danial Ramzan <danialramzan@gmail.com>
pull/724/head
Danial Ramzan 3 weeks ago committed by GitHub
parent 207d15a909
commit 744e840cb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -426,7 +426,11 @@ open class UninitializedApp : Application() {
// the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
private const val DISALLOWED_APPS_KEY = "disallowedApps"
// File for shared preferences that are not encrypted.
private const val ALLOWED_APPS_KEY = "allowedApps"
private const val SPLIT_TUNNEL_KEY = "splitTunnelEnabled"
private const val SPLIT_TUNNEL_MODE_KEY = "split_tunnel_mode"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
@ -599,19 +603,73 @@ open class UninitializedApp : Application() {
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
fun updateUserAllowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserAllowedPackageNames called with empty packageName(s)")
return
}
getUnencryptedPrefs().edit().putStringSet(ALLOWED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN()
}
fun isSplitTunnelEnabled(): Boolean =
getUnencryptedPrefs().getBoolean(SPLIT_TUNNEL_KEY, false)
fun setSplitTunnelEnabled(enabled: Boolean) {
getUnencryptedPrefs().edit().putBoolean(SPLIT_TUNNEL_KEY, enabled).apply()
restartVPN()
}
fun setSplitTunnelMode(mode: SplitTunnelMode) {
getUnencryptedPrefs().edit()
.putString(SPLIT_TUNNEL_MODE_KEY, mode.name)
.apply()
restartVPN()
}
fun getSplitTunnelMode(): SplitTunnelMode {
val stored = getUnencryptedPrefs().getString(SPLIT_TUNNEL_MODE_KEY, null)
return if (stored != null) {
SplitTunnelMode.valueOf(stored)
} else {
SplitTunnelMode.EXCLUDE
}
}
fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) {
if (mdmDisallowed.isNotEmpty()) {
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed
}
val userDisallowed =
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return builtInDisallowedPackageNames + userDisallowed
}
fun getAppScopedViewModel(): AppViewModel {
fun allowedPackageNames(): List<String> {
val mdmAllowed =
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmAllowed.isNotEmpty()) {
TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed")
return mdmAllowed
}
val userAllowed =
getUnencryptedPrefs().getStringSet(ALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return userAllowed
}
fun getAppScopedViewModel(): AppViewModel {
return appViewModel
}
@ -640,4 +698,9 @@ open class UninitializedApp : Application() {
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
"com.google.android.apps.scone",
)
enum class SplitTunnelMode {
INCLUDE,
EXCLUDE
}
}

@ -149,39 +149,82 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
}
override fun newBuilder(): VPNServiceBuilder {
val b: Builder =
Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
val includedPackages: List<String> =
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (includedPackages.isNotEmpty()) {
// If an admin defined a list of packages that are exclusively allowed to be used via
// Tailscale,
// then only allow those apps.
for (packageName in includedPackages) {
TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName)
}
} else {
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
// - any app that the user manually disallowed in the GUI
// - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
}
private fun allowApp(b: Builder, name: String) {
try {
b.addAllowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add allowed application: $e")
}
}
return VPNServiceBuilder(b)
}
override fun newBuilder(): VPNServiceBuilder {
val b: Builder =
Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
val app = UninitializedApp.get()
val mdmAllowed = MDMSettings.includedPackages.flow.value.value?.
split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.
split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
val splitEnabled = app.isSplitTunnelEnabled()
val mode = app.getSplitTunnelMode()
when {
mdmAllowed.isNotEmpty() -> {
TSLog.d(TAG, "MDM include mode, allowed = $mdmAllowed")
mdmAllowed.forEach { pkg ->
TSLog.d(TAG, "Including app via MDM: $pkg")
allowApp(b, pkg)
}
}
mdmDisallowed.isNotEmpty() -> {
val effectiveDisallowed = app.builtInDisallowedPackageNames + mdmDisallowed
TSLog.d(TAG, "MDM exclude mode, disallowed = $effectiveDisallowed")
effectiveDisallowed.forEach { pkg ->
TSLog.d(TAG, "Disallowing app via MDM: $pkg")
disallowApp(b, pkg)
}
}
!splitEnabled -> {
TSLog.d(TAG, "Split tunneling disabled; using built-in disallowed only")
app.builtInDisallowedPackageNames.forEach { pkg ->
TSLog.d(TAG, "Disallowing built-in app: $pkg")
disallowApp(b, pkg)
}
}
mode == UninitializedApp.SplitTunnelMode.INCLUDE -> {
val userAllowed = app.allowedPackageNames()
TSLog.d(TAG, "User INCLUDE mode; allowed = $userAllowed")
userAllowed.forEach { pkg ->
TSLog.d(TAG, "Including app via user INCLUDE: $pkg")
allowApp(b, pkg)
}
}
else -> {
val effectiveDisallowed = app.disallowedPackageNames()
TSLog.d(TAG, "User EXCLUDE mode; disallowed = $effectiveDisallowed")
effectiveDisallowed.forEach { pkg ->
TSLog.d(TAG, "Disallowing app via user EXCLUDE/built-in: $pkg")
disallowApp(b, pkg)
}
}
}
return VPNServiceBuilder(b)
}
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"

@ -120,6 +120,7 @@ class Ipn {
CorpDNSSet = true
}
var ExitNodeID: StableNodeID? = null
set(value) {
field = value

@ -89,7 +89,7 @@ fun SettingsView(
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
subtitle = stringResource(R.string.include_or_exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock.value == ShowHide.Show) {

@ -4,19 +4,35 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
@ -26,6 +42,7 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.SplitTunnelMode
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@ -36,76 +53,286 @@ fun SplitTunnelAppPickerView(
) {
val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val includedPackageNames by model.includedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
val splitEnabled = remember { mutableStateOf(App.get().isSplitTunnelEnabled())}
val currentSplitMode = remember { mutableStateOf(App.get().getSplitTunnelMode())}
val searchQuery = remember { mutableStateOf("") }
val filteredApps = installedApps.filter { app ->
searchQuery.value.isBlank() ||
app.name.contains(searchQuery.value, ignoreCase = true) ||
app.packageName.contains(searchQuery.value, ignoreCase = true)
}
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
ListItem(
val mdmActive =
(mdmExcludedPackages.value?.isNotEmpty() == true) ||
(mdmIncludedPackages.value?.isNotEmpty() == true)
if (mdmActive) {
Setting.Switch(
R.string.split_tunneling_enabled,
isOn = true,
enabled = false,
onToggle = {}
)
} else {
Setting.Switch(
R.string.split_tunneling_enabled,
isOn = splitEnabled.value,
onToggle = {
val newVal = !App.get().isSplitTunnelEnabled()
App.get().setSplitTunnelEnabled(newVal)
splitEnabled.value = App.get().isSplitTunnelEnabled()
}
)
}
ListItem(
headlineContent = {
Text(
stringResource(
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
.selected_apps_will_follow_custom_routing))
})
}
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
})
}
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
})
}
} else {
item("resolversHeader") {
Lists.SectionDivider(
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
}
items(installedApps) { app ->
ListItem(
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
leadingContent = {
Image(
bitmap =
model.installedAppsManager.packageManager
.getApplicationIcon(app.packageName)
.toBitmap()
.asImageBitmap(),
contentDescription = null,
modifier = Modifier.width(40.dp).height(40.dp))
},
supportingContent = {
Text(
app.packageName,
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
},
trailingContent = {
Checkbox(
checked = excludedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
} else {
model.unexclude(packageName = app.packageName)
}
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
})
}
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
})
})
Lists.ItemDivider()
}
} else {
if (splitEnabled.value) {
item("resolversHeader") {
Spacer(modifier = Modifier.height(8.dp))
AppSearchBar(
query = searchQuery.value,
onQueryChange = { searchQuery.value = it }
)
Row(modifier = Modifier.padding(horizontal = 8.dp)) {
FilterChip(
selected = currentSplitMode.value == SplitTunnelMode.EXCLUDE,
onClick = {
App.get().setSplitTunnelMode(SplitTunnelMode.EXCLUDE)
currentSplitMode.value = App.get().getSplitTunnelMode()
},
label = { Text("Exclude apps") }
)
Spacer(modifier = Modifier.width(8.dp))
FilterChip(
selected = currentSplitMode.value == SplitTunnelMode.INCLUDE,
onClick = {
App.get().setSplitTunnelMode(SplitTunnelMode.INCLUDE)
currentSplitMode.value = App.get().getSplitTunnelMode()
},
label = { Text("Include apps") }
)
}
}
if (currentSplitMode.value == SplitTunnelMode.EXCLUDE) {
item("resolversHeaderExclude") {
Lists.SectionDivider(
stringResource(
R.string.count_excluded_apps,
excludedPackageNames.count()
)
)
}
if (filteredApps.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(vertical = 24.dp, horizontal = 16.dp)
) {
Text(
"No apps found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
items(filteredApps) { app ->
ListItem(
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
leadingContent = {
Image(
bitmap =
model.installedAppsManager.packageManager
.getApplicationIcon(app.packageName)
.toBitmap()
.asImageBitmap(),
contentDescription = null,
modifier = Modifier.width(40.dp).height(40.dp)
)
},
supportingContent = {
Text(
app.packageName,
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing
)
},
trailingContent = {
Checkbox(
checked = excludedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
} else {
model.unexclude(packageName = app.packageName)
}
})
})
Lists.ItemDivider()
}
} else {
item("resolversHeaderInclude") {
Lists.SectionDivider(
stringResource(
R.string.count_included_apps,
includedPackageNames.count()
)
)
}
if (filteredApps.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(vertical = 24.dp, horizontal = 16.dp)
) {
Text(
"No apps found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
items(filteredApps) { app ->
ListItem(
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
leadingContent = {
Image(
bitmap =
model.installedAppsManager.packageManager
.getApplicationIcon(app.packageName)
.toBitmap()
.asImageBitmap(),
contentDescription = null,
modifier = Modifier.width(40.dp).height(40.dp)
)
},
supportingContent = {
Text(
app.packageName,
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing
)
},
trailingContent = {
Checkbox(
checked = includedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.include(packageName = app.packageName)
} else {
model.uninclude(packageName = app.packageName)
}
})
})
Lists.ItemDivider()
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSearchBar(
query: String,
onQueryChange: (String) -> Unit
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
value = query,
onValueChange = onQueryChange,
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search"
)
},
shape = SearchBarDefaults.dockedShape,
placeholder = { Text(stringResource(R.string.search_apps_ellipsis)) },
singleLine = true,
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
}
)
}

@ -20,12 +20,16 @@ import kotlinx.coroutines.launch
class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val includedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
private var saveJob: Job? = null
init {
installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set(
@ -33,6 +37,12 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
.disallowedPackageNames()
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList())
includedPackageNames.set(
App.get()
.allowedPackageNames()
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList())
}
fun exclude(packageName: String) {
@ -46,7 +56,18 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
debounceSave()
}
private fun debounceSave() {
fun include(packageName: String) {
if (includedPackageNames.value.contains(packageName)) return
includedPackageNames.set(includedPackageNames.value + packageName)
debounceSaveInclude()
}
fun uninclude(packageName: String) {
includedPackageNames.set(includedPackageNames.value - packageName)
debounceSaveInclude()
}
private fun debounceSave() {
saveJob?.cancel()
saveJob =
viewModelScope.launch {
@ -54,4 +75,32 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
App.get().updateUserDisallowedPackageNames(excludedPackageNames.value)
}
}
private fun debounceSaveInclude() {
saveJob?.cancel()
saveJob =
viewModelScope.launch {
delay(500)
App.get().updateUserAllowedPackageNames(includedPackageNames.value)
}
}
fun toggleSplitTunnel() {
val newValue = !App.get().isSplitTunnelEnabled()
App.get().setSplitTunnelEnabled(newValue)
}
// If MDM inforces split tunnel — write it to sharedprefs
private fun enforceMdMSplitTunnel() {
val mdmActive =
mdmExcludedPackages.value.value?.isNotEmpty() == true ||
mdmIncludedPackages.value.value?.isNotEmpty() == true
if (mdmActive && !App.get().isSplitTunnelEnabled()) {
App.get().setSplitTunnelEnabled(true)
}
}
}

@ -20,6 +20,7 @@
<string name="warning">Warning</string>
<string name="search">Search</string>
<string name="search_ellipsis">Search...</string>
<string name="search_apps_ellipsis">Search apps...</string>
<string name="dismiss">Dismiss</string>
<string name="no_results">No results</string>
<string name="back">Back</string>
@ -309,9 +310,11 @@
<string name="an_unknown_error_occurred_please_try_again">An unknown error occurred. Please try again.</string>
<string name="request_timed_out_make_sure_that_is_online">Request timed out. Make sure that \'%1$s\' is online.</string>
<string name="split_tunneling">App split tunneling</string>
<string name="exclude_certain_apps_from_using_tailscale">Exclude certain apps from using Tailscale</string>
<string name="selected_apps_will_access_the_internet_directly_without_using_tailscale">Apps selected here will access the Internet directly, without using Tailscale.</string>
<string name="split_tunneling_enabled">Enable split tunneling</string>
<string name="include_or_exclude_certain_apps_from_using_tailscale">Include or exclude certain apps from using Tailscale</string>
<string name="selected_apps_will_follow_custom_routing">Apps selected here will follow special routing depending on your split-tunnel configuration.</string>
<string name="count_excluded_apps">Excluded apps (%1$s)</string>
<string name="count_included_apps">Included apps (%1$s)</string>
<string name="certain_apps_are_not_routed_via_tailscale">Certain apps are not routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator.</string>
<string name="only_specific_apps_are_routed_via_tailscale">Only specific apps are routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator.</string>
<string name="specifies_a_list_of_apps_that_will_be_excluded_from_tailscale_routes_and_dns_even_when_tailscale_is_running_all_other_apps_will_use_tailscale">Specifies a list of apps that will be excluded from Tailscale routes and DNS even when Tailscale is running. All other apps will use Tailscale.</string>

Loading…
Cancel
Save