Merge branch 'main' into task/improve-app-split-tunneling-feature

pull/621/head
davfsa 2 months ago
commit 9583c7af0a
No known key found for this signature in database
GPG Key ID: 8B2D9E0036D67C1C

@ -13,19 +13,6 @@
DOCKER_IMAGE := tailscale-android-build-amd64-041425-1
export TS_USE_TOOLCHAIN=1
# Auto-select an NDK from ANDROID_HOME (choose highest version available)
NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1)
HOST_OS := $(shell uname | tr A-Z a-z)
ifeq ($(HOST_OS),linux)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy
else ifeq ($(HOST_OS),darwin)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy
endif
$(info Using NDK_ROOT: $(NDK_ROOT))
$(info Using STRIP_TOOL: $(STRIP_TOOL))
DEBUG_APK := tailscale-debug.apk
RELEASE_AAB := tailscale-release.aab
RELEASE_TV_AAB := tailscale-tv-release.aab
@ -64,6 +51,20 @@ ifeq ($(ANDROID_SDK_ROOT),)
endif
export ANDROID_HOME ?= $(ANDROID_SDK_ROOT)
# Auto-select an NDK from ANDROID_HOME (choose highest version available)
NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1)
HOST_OS := $(shell uname | tr A-Z a-z)
ifeq ($(HOST_OS),linux)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy
else ifeq ($(HOST_OS),darwin)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy
endif
$(info Using ANDROID_HOME: $(ANDROID_HOME))
$(info Using NDK_ROOT: $(NDK_ROOT))
$(info Using STRIP_TOOL: $(STRIP_TOOL))
# Attempt to find Android Studio for Linux configuration, which does not have a
# predetermined location.
ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1)
@ -179,9 +180,11 @@ build-unstripped-aar: tailscale.version $(GOBIN)/gomobile
@echo "Output file: $(ABS_UNSTRIPPED_AAR)"
mkdir -p $(dir $(ABS_UNSTRIPPED_AAR))
rm -f $(ABS_UNSTRIPPED_AAR)
# The -linkmode=external -extldflags=-Wl,-z,max-page-size=16384 is specific to NDK 23
# to support 16kb page sizes. Your mileage may vary with other NDK versions.
$(GOBIN)/gomobile bind -target android -androidapi 26 \
-tags "$$(./build-tags.sh)" \
-ldflags "$$(./version-ldflags.sh)" \
-ldflags "-linkmode=external -extldflags=-Wl,-z,max-page-size=16384 $$(./version-ldflags.sh)" \
-o $(ABS_UNSTRIPPED_AAR) ./libtailscale || { echo "gomobile bind failed"; exit 1; }
@if [ ! -f $(ABS_UNSTRIPPED_AAR) ]; then \
echo "Error: $(ABS_UNSTRIPPED_AAR) was not created"; exit 1; \
@ -275,10 +278,10 @@ bump_version_code:
.PHONY: update-oss ## Update the tailscale.com go module
update-oss:
curl -f https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/go.toolchain.rev > go.toolchain.rev.new
mv go.toolchain.rev.new go.toolchain.rev
GOPROXY=direct ./tool/go get tailscale.com@main
./tool/go mod tidy -compat=1.24
./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new
mv go.toolchain.rev.new go.toolchain.rev
# Get the commandline tools package, this provides (among other things) the sdkmanager binary.
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
@ -286,7 +289,7 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mkdir -p $(ANDROID_HOME)/cmdline-tools
(cd $(ANDROID_HOME)/tmp && \
curl --silent -O -L $(ANDROID_TOOLS_URL) && \
echo $(ANDROID_TOOLS_SUM) | sha256sum -c && \
echo $(ANDROID_TOOLS_SUM) | shasum -c - && \
unzip $(shell basename $(ANDROID_TOOLS_URL)))
mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest
rm -rf $(ANDROID_HOME)/tmp
@ -312,6 +315,14 @@ checkandroidsdk: ## Check that Android SDK is installed
test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test)
.PHONY: fmt
fmt: gradle-dependencies ## Format the Android code
(cd android && ./gradlew ktfmtFormat)
.PHONY: fmt-check
fmt-check: gradle-dependencies ## Check the Android code is formatted
(cd android && ./gradlew ktfmtCheck)
.PHONY: emulator
emulator: ## Start an android emulator instance
@echo "Checking installed SDK packages..."

@ -36,7 +36,7 @@ android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
targetSdkVersion 35
versionCode 356
versionName getVersionProperty("VERSION_LONG")
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

@ -7,15 +7,15 @@ import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.RestrictionsManager
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
@ -33,11 +33,13 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.HardwareKeyStore
import com.tailscale.ipn.util.NoSuchKeyException
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
@ -53,15 +55,16 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.lang.UnsupportedOperationException
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences.
private val PREF_KEY_SAF_URI = "saf_directory_uri"
private const val TAG = "App"
private lateinit var appInstance: App
/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
@ -77,12 +80,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
private lateinit var connectivityManager: ConnectivityManager
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore
get() = appViewModelStore
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
@ -106,11 +107,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
super.onCreate()
appInstance = this
setUnprotectedInstance(this)
mdmChangeReceiver = MDMSettingsChangedReceiver()
val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
registerReceiver(mdmChangeReceiver, filter)
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
@ -144,28 +143,25 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
if (isInitialized) {
return
}
initializeApp()
isInitialized = true
}
private fun initializeApp() {
val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
if (storedUri != null && storedUri.toString().startsWith("content://")) {
startLibtailscale(storedUri.toString())
} else {
startLibtailscale(this.filesDir.absolutePath)
}
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
applicationScope.launch {
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(get(), rm)
Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
state,
@ -183,11 +179,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
if (state == Ipn.State.Stopped) {
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(
@ -204,9 +198,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
TSLog.init(this)
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}
/**
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/
fun startLibtailscale(directFileRoot: String) {
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
appViewModel =
ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt()))
.get(AppViewModel::class.java)
}
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
@ -220,14 +227,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class)
override fun encryptToPref(prefKey: String?, plaintext: String?) {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
@Throws(IOException::class, GeneralSecurityException::class)
@ -235,10 +240,20 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return getEncryptedPrefs().getString(prefKey, null)
}
override fun getStateStoreKeysJSON(): String {
val prefix = "statestore-"
val keys =
getEncryptedPrefs()
.getAll()
.keys
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
return org.json.JSONArray(keys).toString()
}
@Throws(IOException::class, GeneralSecurityException::class)
fun getEncryptedPrefs(): SharedPreferences {
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
@ -247,6 +262,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) }
}
/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
@ -278,7 +297,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
val sb = StringBuilder()
for (nif in interfaces) {
try {
@ -294,7 +312,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
nif.isLoopback,
nif.isPointToPoint,
nif.supportsMulticast()))
for (ia in nif.interfaceAddresses) {
val parts = ia.toString().split("/", limit = 0)
if (parts.size > 1) {
@ -306,33 +323,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
sb.append("\n")
}
return sb.toString()
}
private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("")
}
}
return downloads
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
@ -368,8 +361,49 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
fun notifyPolicyChanged() {
app.notifyPolicyChanged()
}
}
override fun hardwareAttestationKeySupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
} else {
false
}
}
private lateinit var keyStore: HardwareKeyStore;
private fun getKeyStore(): HardwareKeyStore {
if (hardwareAttestationKeySupported()) {
return HardwareKeyStore()
} else {
throw UnsupportedOperationException()
}
}
override fun hardwareAttestationKeyCreate(): String {
return getKeyStore().createKey()
}
@Throws(NoSuchKeyException::class)
override fun hardwareAttestationKeyRelease(id: String) {
return getKeyStore().releaseKey(id)
}
@Throws(NoSuchKeyException::class)
override fun hardwareAttestationKeySign(id: String, data: ByteArray): ByteArray {
return getKeyStore().sign(id, data)
}
@Throws(NoSuchKeyException::class)
override fun hardwareAttestationKeyPublic(id: String): ByteArray {
return getKeyStore().public(id)
}
@Throws(NoSuchKeyException::class)
override fun hardwareAttestationKeyLoad(id: String) {
return getKeyStore().load(id)
}
}
/**
* UninitializedApp contains all of the methods of App that can be used without having to initialize
* the Go backend. This is useful when you want to access functions on the App without creating side
@ -378,11 +412,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
open class UninitializedApp : Application() {
companion object {
const val TAG = "UninitializedApp"
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status"
// Key for shared preference that tracks whether or not we're able to start
// the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
@ -397,17 +429,15 @@ open class UninitializedApp : Application() {
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
lateinit var appViewModel: AppViewModel
@JvmStatic
fun get(): UninitializedApp {
return appInstance
}
/**
* Return the name of the active (but not the selected/prior one) exit node based on the
* provided [Ipn.Prefs] and [Netmap.NetworkMap].
@ -428,7 +458,6 @@ open class UninitializedApp : Application() {
protected fun setAbleToStartVPN(rdy: Boolean) {
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
}
/** This function can be called without initializing the App. */
fun isAbleToStartVPN(): Boolean {
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
@ -450,7 +479,6 @@ open class UninitializedApp : Application() {
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
)
try {
pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) {
@ -476,25 +504,15 @@ open class UninitializedApp : Application() {
}
fun restartVPN() {
// Register a receiver to listen for the completion of stopVPN
val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Ensure stop intent is complete
if (intent?.action == IPNService.ACTION_STOP_VPN) {
// Unregister receiver after receiving the broadcast
context?.unregisterReceiver(this)
// Now start the VPN
startVPN()
}
}
val intent =
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
try {
startService(intent)
} catch (illegalStateException: IllegalStateException) {
TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) {
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
}
// Register the receiver before stopping VPN
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
this.registerReceiver(stopReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
stopVPN()
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
@ -548,7 +566,6 @@ open class UninitializedApp : Application() {
0,
buttonIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -556,7 +573,6 @@ open class UninitializedApp : Application() {
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
@ -575,32 +591,13 @@ open class UninitializedApp : Application() {
return builder.build()
}
fun addUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserSelectedPackage called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()
this.restartVPN()
}
fun removeUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserSelectedPackage called with empty packageName")
fun updateUserSelectedPackages(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserSelectedPackage called with empty packageName(s)")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
getUnencryptedPrefs().edit().putStringSet(SELECTED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN()
}
@ -624,8 +621,8 @@ open class UninitializedApp : Application() {
return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, false)
}
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
fun getAppScopedViewModel(): AppViewModel {
return appViewModel
}
val builtInDisallowedPackageNames: List<String> =

@ -46,6 +46,13 @@ open class IPNService : VpnService(), libtailscale.IPNService {
close()
START_NOT_STICKY
}
ACTION_RESTART_VPN -> {
app.setWantRunning(false) {
close()
app.startVPN()
}
START_NOT_STICKY
}
ACTION_START_VPN -> {
scope.launch { showForegroundNotification() }
app.setWantRunning(true)
@ -82,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
override fun close() {
app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping)
disconnectVPN()
Libtailscale.serviceDisconnect(this)
@ -214,5 +220,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN"
}
}

@ -10,17 +10,22 @@ import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.LinearOutSlowInEasing
@ -29,10 +34,18 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
@ -44,6 +57,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
@ -66,22 +80,28 @@ import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.NotificationsView
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.PrimaryActionButton
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -92,12 +112,10 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
val app = App.get()
vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
private lateinit var appViewModel: AppViewModel
private lateinit var viewModel: MainViewModel
val permissionsViewModel: PermissionsViewModel by viewModels()
companion object {
private const val TAG = "Main Activity"
@ -108,7 +126,6 @@ class MainActivity : ComponentActivity() {
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
SCREENLAYOUT_SIZE_LARGE
}
// The loginQRCode is used to track whether or not we should be rendering a QR code
// to the user. This is used only on TV platforms with no browser in lieu of
// simply opening the URL. This should be consumed once it has been handled.
@ -121,21 +138,27 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes
App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
appViewModel = (application as App).getAppScopedViewModel()
viewModel =
ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java)
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), rm)
if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide ||
MDMSettings.authKey.flow.value.value != null) {
setIntroScreenViewed(true)
}
// (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support
if (!isLandscapeCapable()) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true)
appViewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
if (isAnotherVpnActive(this)) {
@ -143,13 +166,91 @@ class MainActivity : ComponentActivity() {
showOtherVPNConflictDialog()
} else {
TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false)
appViewModel.setVpnPrepared(false)
AlertDialog.Builder(this)
.setTitle(R.string.vpn_permission_needed)
.setMessage(R.string.vpn_explainer)
.setPositiveButton(R.string.try_again) { _, _ ->
viewModel.showVPNPermissionLauncherIfUnauthorized()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri != null) {
try {
// Try to take persistable permissions for both read and write.
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} catch (e: SecurityException) {
TSLog.e("MainActivity", "Failed to persist permissions: $e")
}
// Check if write permission is actually granted.
val writePermission =
this.checkUriPermission(
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (writePermission == PackageManager.PERMISSION_GRANTED) {
TSLog.d("MainActivity", "Write permission granted for $uri")
lifecycleScope.launch(Dispatchers.IO) {
try {
TaildropDirectoryStore.saveFileDirectory(uri)
permissionsViewModel.refreshCurrentDir()
ShareFileHelper.notifyDirectoryReady()
ShareFileHelper.setUri(uri.toString())
} catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
}
}
} else {
TSLog.d(
"MainActivity",
"Write access not granted for $uri. Falling back to internal storage.")
// Don't save directory URI and fall back to internal storage.
}
} else {
TSLog.d(
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
// Fall back to internal storage.
}
}
appViewModel.directoryPickerLauncher = directoryPickerLauncher
setContent {
var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } }
if (showDialog) {
AppTheme {
AlertDialog(
onDismissRequest = {
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
},
title = {
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
},
text = { TaildropDirectoryPickerPrompt() },
confirmButton = {
PrimaryActionButton(
onClick = {
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
}) {
Text(text = stringResource(id = R.string.taildrop_directory_picker_button))
}
})
}
}
navController = rememberNavController()
AppTheme {
@ -185,7 +286,6 @@ class MainActivity : ComponentActivity() {
fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false)
}
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
@ -198,7 +298,6 @@ class MainActivity : ComponentActivity() {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
val settingsNav =
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
@ -213,7 +312,6 @@ class MainActivity : ComponentActivity() {
onNavigateToPermissions = { navController.navigate("permissions") },
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateBackHome = {
@ -225,7 +323,6 @@ class MainActivity : ComponentActivity() {
onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
@ -236,7 +333,11 @@ class MainActivity : ComponentActivity() {
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
MainView(
loginAtUrl = ::login,
navigation = mainViewNav,
viewModel = viewModel,
appViewModel = appViewModel)
}
composable("search") {
val autoFocus = viewModel.autoFocusSearch
@ -246,7 +347,9 @@ class MainActivity : ComponentActivity() {
onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus)
}
composable("settings") { SettingsView(settingsNav) }
composable("settings") {
SettingsView(settingsNav = settingsNav, appViewModel = appViewModel)
}
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
@ -277,7 +380,17 @@ class MainActivity : ComponentActivity() {
composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
composable("permissions") {
PermissionsView(backTo("settings"), ::openApplicationSettings)
PermissionsView(
backTo("settings"),
{ navController.navigate("taildropDir") },
{ navController.navigate("notifications") })
}
composable("taildropDir") {
TaildropDirView(
backTo("permissions"), directoryPickerLauncher, permissionsViewModel)
}
composable("notifications") {
NotificationsView(backTo("permissions"), ::openApplicationSettings)
}
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main"))
@ -290,15 +403,12 @@ class MainActivity : ComponentActivity() {
onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
}
// Show the intro screen one time
if (!introScreenViewed()) {
if (isIntroScreenViewedSet()) {
navController.navigate("intro")
setIntroScreenViewed(true)
}
}
}
// Login actions are app wide. If we are told about a browse-to-url, we should render it
// over whatever screen we happen to be on.
loginQRCode.collectAsState().value?.let {
@ -321,7 +431,6 @@ class MainActivity : ComponentActivity() {
}
}
}
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
}
@ -342,7 +451,6 @@ class MainActivity : ComponentActivity() {
fun isAnotherVpnActive(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork != null) {
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
@ -353,7 +461,6 @@ class MainActivity : ComponentActivity() {
}
return false
}
// Returns true if we should render a QR code instead of launching a browser
// for login requests
private fun useQRCodeLogin(): Boolean {
@ -366,7 +473,9 @@ class MainActivity : ComponentActivity() {
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
@ -378,6 +487,7 @@ class MainActivity : ComponentActivity() {
}
}
}
}
private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
@ -394,7 +504,6 @@ class MainActivity : ComponentActivity() {
putExtra(START_AT_ROOT, true)
}
startActivity(intent)
// Cancel coroutine once we've logged in
this@launch.cancel()
}
@ -403,7 +512,6 @@ class MainActivity : ComponentActivity() {
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
}
}
val url = urlString.toUri()
try {
val customTabsIntent = CustomTabsIntent.Builder().build()
@ -426,10 +534,6 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
override fun onStart() {
super.onStart()
}
override fun onStop() {
super.onStop()
val restrictionsManager =
@ -446,8 +550,8 @@ class MainActivity : ComponentActivity() {
startActivity(intent)
}
private fun introScreenViewed(): Boolean {
return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
private fun isIntroScreenViewedSet(): Boolean {
return !getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
}
private fun setIntroScreenViewed(seen: Boolean) {

@ -60,9 +60,13 @@ public class QuickToggleService extends TileService {
}
}
@SuppressWarnings("deprecation")
@Override
public void onClick() {
unlockAndRun(this::secureOnClick);
}
@SuppressWarnings("deprecation")
private void secureOnClick() {
boolean r;
synchronized (lock) {
r = UninitializedApp.get().isAbleToStartVPN();

@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.net.Uri
import com.tailscale.ipn.util.TSLog
import java.io.IOException
import java.security.GeneralSecurityException
object TaildropDirectoryStore {
// Key to store the SAF URI in EncryptedSharedPreferences.
val PREF_KEY_SAF_URI = "saf_directory_uri"
@Throws(IOException::class, GeneralSecurityException::class)
fun saveFileDirectory(directoryUri: Uri) {
val prefs = App.get().getEncryptedPrefs()
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit()
}
@Throws(IOException::class, GeneralSecurityException::class)
fun loadSavedDir(): Uri? {
val prefs = App.get().getEncryptedPrefs()
val uriString = prefs.getString(PREF_KEY_SAF_URI, null) ?: return null
return try {
Uri.parse(uriString)
} catch (e: Exception) {
// Malformed URI in prefs log and wipe the bad value
TSLog.w("MainActivity", "loadSavedDir: invalid URI in prefs: $uriString; clearing")
prefs.edit().remove(PREF_KEY_SAF_URI).apply()
null
}
}
}

@ -59,6 +59,11 @@ object MDMSettings {
val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
// Handled on the backend
val deviceSerialNumber =
StringMDMSetting(
"DeviceSerialNumber", "Serial number of the device that is running Tailscale")
val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
@ -97,6 +102,9 @@ object MDMSettings {
// Overrides the value provided by os.Hostname() in Go
val hostname = StringMDMSetting("Hostname", "Device Hostname")
// Allows admins to skip the get started intro screen
val onboardingFlow = ShowHideMDMSetting("OnboardingFlow", "Suppress the intro screen")
val allSettings by lazy {
MDMSettings::class
.declaredMemberProperties

@ -95,11 +95,11 @@ class Ipn {
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var LoggedOutSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) {
var ControlURL: String? = null
@ -126,12 +126,6 @@ class Ipn {
ExitNodeIDSet = true
}
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
@ -144,6 +138,12 @@ class Ipn {
WantRunningSet = true
}
var LoggedOut: Boolean? = null
set(value) {
field = value
LoggedOutSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
@ -238,3 +238,20 @@ class Persist {
var Provider: String = "",
)
}
fun Ipn.MaskedPrefs.deepCopy(): Ipn.MaskedPrefs {
return Ipn.MaskedPrefs().also {
if (this.ControlURLSet == true) it.ControlURL = this.ControlURL
if (this.RouteAllSet == true) it.RouteAll = this.RouteAll
if (this.CorpDNSSet == true) it.CorpDNS = this.CorpDNS
if (this.ExitNodeIDSet == true) it.ExitNodeID = this.ExitNodeID
if (this.ExitNodeAllowLANAccessSet == true)
it.ExitNodeAllowLANAccess = this.ExitNodeAllowLANAccess
if (this.WantRunningSet == true) it.WantRunning = this.WantRunning
if (this.LoggedOutSet == true) it.LoggedOut = this.LoggedOut
if (this.ShieldsUpSet == true) it.ShieldsUp = this.ShieldsUp
if (this.AdvertiseRoutesSet == true) it.AdvertiseRoutes = this.AdvertiseRoutes
if (this.ForceDaemonSet == true) it.ForceDaemon = this.ForceDaemon
if (this.HostnameSet == true) it.Hostname = this.Hostname
}
}

@ -15,7 +15,8 @@ class Netmap {
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null
var DNS: Tailcfg.DNSConfig? = null,
var AllCaps: List<String> = emptyList()
) {
// Keys are tailcfg.UserIDs thet get stringified
// Helpers
@ -51,5 +52,9 @@ class Netmap {
UserProfiles == other.UserProfiles &&
TKAEnabled == other.TKAEnabled
}
fun hasCap(capability: String): Boolean {
return AllCaps.contains(capability)
}
}
}

@ -105,10 +105,15 @@ class Tailcfg {
// isExitNode reproduces the Go logic in local.go peerStatusFromNode
val isExitNode: Boolean =
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
(AllowedIPs?.contains("0.0.0.0/0") ?: false) && (AllowedIPs?.contains("::/0") ?: false)
// mullvad nodes are exit nodes with a mullvad.ts.net domain *or* Location Info.
// These checks are intentionally redundant to avoid false negatives.
val isMullvadNode: Boolean
get() = Name.endsWith(".mullvad.ts.net.")
get() =
Name.endsWith(".mullvad.ts.net") ||
ComputedName?.endsWith(".mullvad.ts.net") == true ||
Hostinfo.Location != null
val displayName: String
get() = ComputedName ?: Name
@ -183,7 +188,15 @@ class Tailcfg {
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
@Serializable
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
data class NetworkProfile(
var MagicDNSName: String? = null,
var DomainName: String? = null,
var DisplayName: String? = null
) {
fun tailnetNameForDisplay(): String? {
return DisplayName ?: DomainName
}
}
@Serializable
data class Location(

@ -13,10 +13,13 @@ import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil {
private val FEATURE_FIRETV = "amazon.hardware.fire_tv"
fun isAndroidTV(): Boolean {
val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
pm.hasSystemFeature(FEATURE_FIRETV))
}
}

@ -0,0 +1,26 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.util.TSLog
import java.io.OutputStream
// This class adapts a Java OutputStream to the libtailscale.OutputStream interface.
class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream {
// writes data to the outputStream in its entirety. Returns -1 on error.
override fun write(data: ByteArray): Long {
return try {
outputStream.write(data)
outputStream.flush()
data.size.toLong()
} catch (e: Exception) {
TSLog.d("OutputStreamAdapter", "write exception: $e")
-1L
}
}
override fun close() {
outputStream.close()
}
}

@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.net.Uri
/** Converts a SAF URI string to a more human-friendly folder display name. */
fun friendlyDirName(uriStr: String): String {
val uri = Uri.parse(uriStr)
val segment = uri.lastPathSegment ?: return uriStr
return when {
segment.startsWith("primary:") -> "Internal storage " + segment.removePrefix("primary:")
segment.contains(":") -> {
val folder = segment.substringAfter(":")
"SD card $folder"
}
else -> segment
}
}

@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.AndroidTVUtil
@ -44,6 +45,7 @@ fun Avatar(
) {
val isFocused = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val isIconLoaded = remember { mutableStateOf(false) }
// Outer Box for the larger focusable and clickable area
Box(
@ -73,20 +75,28 @@ fun Avatar(
contentAlignment = Alignment.Center,
modifier = Modifier.size(size.dp).clip(CircleShape)) {
// Always display the default icon as a background layer
if (!isIconLoaded.value) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title),
modifier =
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) })
Modifier.conditional(
AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) })
.clip(CircleShape) // Icon size slightly smaller than the Box
)
}
// Overlay the profile picture if available
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(
model = url,
modifier = Modifier.size(size.dp).clip(CircleShape),
contentDescription = null)
contentDescription = null,
onState = { state ->
if (state is AsyncImagePainter.State.Success) {
isIconLoaded.value = true
}
})
}
}
}

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
@ -140,7 +142,10 @@ fun LoginView(
placeholder = {
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None))
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { onSubmitAction(textVal) }))
})
ListItem(

@ -1,6 +1,5 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.os.Build
@ -61,20 +60,26 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
@ -101,10 +106,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.FeatureFlags
import kotlinx.coroutines.flow.emptyFlow
// Navigation actions for the MainView
data class MainViewNavigation(
@ -120,7 +126,8 @@ data class MainViewNavigation(
fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel
viewModel: MainViewModel,
appViewModel: AppViewModel
) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState()
@ -146,7 +153,6 @@ fun MainView(
// Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = {
@ -161,7 +167,7 @@ fun MainView(
}
},
headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain ->
user?.NetworkProfile?.tailnetNameForDisplay()?.let { domain ->
AutoResizingText(
text = domain,
style = MaterialTheme.typography.titleMedium.short,
@ -202,23 +208,19 @@ fun MainView(
}
}
})
when (state) {
Ipn.State.Running -> {
PromptPermissionsIfNecessary()
viewModel.showVPNPermissionLauncherIfUnauthorized()
viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel)
PromptForMissingPermissions(viewModel)
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
@ -243,7 +245,6 @@ fun MainView(
}
}
}
currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel)
@ -253,24 +254,45 @@ fun MainView(
}
}
@Composable
fun TaildropDirectoryPickerPrompt() {
val uriHandler = LocalUriHandler.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) {
Text(text = stringResource(id = R.string.taildrop_directory_picker_body))
Text(
text = stringResource(id = R.string.taildrop_directory_picker_info),
modifier = Modifier.clickable { uriHandler.openUri(Links.TAILDROP_KB_URL) },
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline)
}
}
@Composable
fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current
val shouldRequest by viewModel.requestVpnPermission.collectAsState()
LaunchedEffect(shouldRequest) {
if (!shouldRequest) return@LaunchedEffect
// Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.showVPNPermissionLauncherIfUnauthorized()
}
}
}
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val nodeState by viewModel.nodeState.collectAsState()
val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState()
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val name = exitNodePeer?.exitNodeName
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
Box(
modifier =
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
@ -295,7 +317,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
}
}
}
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
@ -415,11 +436,11 @@ fun ConnectView(
loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
showVPNPermissionLauncher: () -> Unit,
) {
LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
showVPNPermissionLauncher()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
@ -479,7 +500,7 @@ fun ConnectView(
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
val tailnetName = user.NetworkProfile?.DomainName ?: ""
val tailnetName = user.NetworkProfile?.tailnetNameForDisplay() ?: ""
Text(
buildAnnotatedString {
append(stringResource(id = R.string.connect_to_tailnet_prefix))
@ -527,13 +548,12 @@ fun PeerList(
viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearchBarClick: () -> Unit,
onSearch: (String) -> Unit
onSearch: (String) -> Unit,
) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isSearchFocussed by remember { mutableStateOf(false) }
@ -542,7 +562,6 @@ fun PeerList(
val localClipboardManager = LocalClipboardManager.current
// Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375)
val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) {
Search(onSearchBarClick)
@ -589,7 +608,6 @@ fun PeerList(
}
}
}
// Peers display
LazyColumn(
modifier =
@ -597,7 +615,6 @@ fun PeerList(
.weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found
if (showNoResults) {
item {
@ -613,7 +630,6 @@ fun PeerList(
fontWeight = FontWeight.Light)
}
}
// Iterate over peer sets to display them
var first = true
peerList.forEach { peerSet ->
@ -621,13 +637,11 @@ fun PeerList(
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier =
@ -694,7 +708,6 @@ fun PeerList(
@Composable
fun NodesSectionHeader(peerSet: PeerSet) {
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
@ -706,7 +719,6 @@ fun NodesSectionHeader(peerSet: PeerSet) {
@Composable
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
modifier =
@ -733,7 +745,10 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PromptPermissionsIfNecessary() {
fun PromptForMissingPermissions(viewModel: MainViewModel) {
if (viewModel.skipPromptsForAuthKeyLogin()) {
return
}
Permissions.prompt.forEach { (permission, state) ->
ErrorDialog(
title = permission.title,
@ -748,11 +763,10 @@ fun PromptPermissionsIfNecessary() {
@Composable
fun Search(
onSearchBarClick: () -> Unit, // Callback for navigating to SearchView
backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color
backgroundColor: Color = MaterialTheme.colorScheme.background, // Default background color
) {
// Prevent multiple taps
var isNavigating by remember { mutableStateOf(false) }
Box(
modifier =
Modifier.fillMaxWidth()
@ -783,7 +797,6 @@ fun Search(
Modifier.padding(start = 0.dp) // Optional start padding for alignment
)
Spacer(modifier = Modifier.width(4.dp))
// Placeholder Text
Text(
text = stringResource(R.string.search_ellipsis),
@ -801,9 +814,9 @@ fun Search(
@Preview
@Composable
fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
val fakePrompt = emptyFlow<Unit>()
val appViewModel = AppViewModel(App.get(), fakePrompt)
val vm = MainViewModel(appViewModel)
MainView(
{},
MainViewNavigation(
@ -812,5 +825,6 @@ fun MainViewPreview() {
onNavigateToExitNodes = {},
onNavigateToHealth = {},
onNavigateToSearch = {}),
vm)
vm,
appViewModel)
}

@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
@Composable
fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
// Find the notification permission
val notificationPermission =
permissions.find { (permission, _) ->
permission.title == R.string.permission_post_notifications
}
val granted = notificationPermission?.second ?: false
val permission = notificationPermission?.first
Scaffold(
topBar = {
Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
if (permission != null) {
ListItem(
headlineContent = {
Text(
stringResource(permission.title),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(permission.description),
style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.notification_settings_explanation),
style = MaterialTheme.typography.bodyMedium)
}
})
}
}
item("spacer") {
Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider
}
item {
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.permission_post_notifications),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text =
if (granted) stringResource(R.string.on)
else stringResource(R.string.off),
style = MaterialTheme.typography.bodyMedium)
Button(
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
onClick = openApplicationSettings,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.open_notification_settings))
}
}
})
}
}
}
}

@ -13,33 +13,38 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.util.friendlyDirName
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
fun PermissionsView(
backToSettings: BackNavigation,
navToTaildropDirView: () -> Unit,
navToNotificationsView: () -> Unit,
permissionsViewModel: PermissionsViewModel = viewModel()
) {
val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
// Existing Android runtime permissions
itemsWithDividers(permissions) { (permission, granted) ->
ListItem(
modifier = Modifier.clickable { openApplicationSettings() },
modifier = Modifier.clickable { navToNotificationsView() },
leadingContent = {
Icon(
if (granted) painterResource(R.drawable.check_circle)
else painterResource(R.drawable.xmark_circle),
tint =
if (granted) MaterialTheme.colorScheme.success
else MaterialTheme.colorScheme.onSurfaceVariant,
painterResource(R.drawable.baseline_notifications_none_24),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
contentDescription =
stringResource(if (granted) R.string.ok else R.string.warning))
@ -47,8 +52,34 @@ fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: ()
headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
},
supportingContent = { Text(stringResource(permission.description)) },
)
supportingContent = {
if (granted) Text(stringResource(R.string.on)) else Text(stringResource(R.string.off))
})
}
item {
ListItem(
modifier = Modifier.clickable { navToTaildropDirView() },
leadingContent = {
Icon(
painterResource(R.drawable.baseline_drive_folder_upload_24),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
contentDescription = stringResource(R.string.taildrop_dir))
},
headlineContent = {
Text(
stringResource(R.string.taildrop_dir_access),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
val displayPath =
permissionsViewModel.currentDir.collectAsState().value?.let {
friendlyDirName(it)
} ?: "No access"
Text(displayPath)
})
}
}
}

@ -38,15 +38,15 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AppVersion
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
@Composable
fun SettingsView(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel()
appViewModel: AppViewModel = viewModel()
) {
val handler = LocalUriHandler.current
@ -55,7 +55,7 @@ fun SettingsView(
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val isVPNPrepared by appViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()

@ -0,0 +1,82 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.friendlyDirName
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
import com.tailscale.ipn.util.TSLog
@Composable
fun TaildropDirView(
backToPermissionsView: BackNavigation,
openDirectoryLauncher: ActivityResultLauncher<Uri?>,
permissionsViewModel: PermissionsViewModel
) {
Scaffold(
topBar = {
Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
ListItem(
headlineContent = {
Text(
stringResource(R.string.taildrop_dir_access),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Text(
text = stringResource(R.string.permission_taildrop_dir),
style = MaterialTheme.typography.bodyMedium)
})
}
item("divider0") { Lists.SectionDivider() }
item {
val currentDir by permissionsViewModel.currentDir.collectAsState()
TSLog.d("TaildropDirView", "currentDir in UI: $currentDir")
val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access"
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.dir_access),
style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = displayPath, style = MaterialTheme.typography.bodyMedium)
Button(
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
onClick = { openDirectoryLauncher.launch(null) },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.pick_dir))
}
}
})
}
}
}
}

@ -3,6 +3,8 @@
package com.tailscale.ipn.ui.view
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -10,8 +12,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@ -20,13 +24,19 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@ -46,10 +56,14 @@ data class UserSwitcherNav(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
val users by viewModel.loginProfiles.collectAsState()
val currentUser by viewModel.loggedInUser.collectAsState()
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val netmapState by viewModel.netmap.collectAsState()
val capabilityIsOwner = "https://tailscale.com/cap/is-owner"
val isOwner = netmapState?.hasCap(capabilityIsOwner) == true
Scaffold(
topBar = {
@ -138,9 +152,46 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
}
})
}
Lists.SectionDivider()
Setting.Text(R.string.delete_tailnet, destructive = true) {
showDeleteDialog = true
}
}
}
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text(text = stringResource(R.string.delete_tailnet)) },
text = {
if (isOwner) {
OwnerDeleteDialogText {
val uri = Uri.parse("https://login.tailscale.com/admin/settings/general")
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}
} else {
Text(stringResource(R.string.request_deletion_nonowner))
}
},
confirmButton = {
TextButton(
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse("https://tailscale.com/contact/support"))
context.startActivity(intent)
showDeleteDialog = false
}) {
Text(text = stringResource(R.string.contact_support))
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
})
}
}
@ -171,6 +222,41 @@ fun FusMenu(
}
}
@Composable
fun OwnerDeleteDialogText(onSettingsClick: () -> Unit) {
val part1 = stringResource(R.string.request_deletion_owner_part1)
val part2a = stringResource(R.string.request_deletion_owner_part2a)
val part2b = stringResource(R.string.request_deletion_owner_part2b)
val annotatedText = buildAnnotatedString {
append(part1 + " ")
pushStringAnnotation(
tag = "settings", annotation = "https://login.tailscale.com/admin/settings/general")
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append("Settings > General")
}
pop()
append(" $part2a\n\n") // newline after "Delete tailnet."
append(part2b)
}
val context = LocalContext.current
ClickableText(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
onClick = { offset ->
annotatedText
.getStringAnnotations(tag = "settings", start = offset, end = offset)
.firstOrNull()
?.let { annotation ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
context.startActivity(intent)
}
})
}
@Composable
fun MenuItem(text: String, onClick: () -> Unit) {
DropdownMenuItem(

@ -63,7 +63,7 @@ fun UserView(
supportingContent = {
Column {
AutoResizingText(
text = profile.NetworkProfile?.DomainName ?: "",
text = profile.NetworkProfile?.tailnetNameForDisplay() ?: "",
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)

@ -0,0 +1,119 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.app.Application
import android.net.Uri
import android.net.VpnService
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow<Unit>) :
ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
return AppViewModel(application, taildropPrompt) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Application context-aware ViewModel used to track app-wide VPN and Taildrop state.
// This must be application-scoped because Tailscale may be enabled, disabled, or used for
// file transfers (Taildrop) outside the activity lifecycle.
//
// Responsibilities:
// - Track VPN preparation state (e.g., whether permission has been granted) and activity state
// - Monitor incoming Taildrop file transfers
// - Coordinate prompts for Taildrop directory selection if not yet configured
class AppViewModel(application: Application, private val taildropPrompt: Flow<Unit>) :
AndroidViewModel(application) {
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
// Select Taildrop directory
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
val TAG = "AppViewModel"
init {
observeIncomingTaildrop()
prepareVpn()
}
private fun observeIncomingTaildrop() {
viewModelScope.launch {
taildropPrompt.collect {
TSLog.d(TAG, "Taildrop event received, checking directory")
checkIfTaildropDirectorySelected()
}
}
}
fun requestDirectoryPicker() {
_triggerDirectoryPicker.tryEmit(Unit)
}
private fun prepareVpn() {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(getApplication())
if (vpnIntent != null) {
setVpnPrepared(false)
Log.d(TAG, "VpnService.prepare returned non-null intent")
} else {
setVpnPrepared(true)
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
}
}
}
fun checkIfTaildropDirectorySelected() {
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (ShareFileHelper.hasValidTaildropDir()) {
return
}
val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) }
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
viewModelScope.launch { requestDirectoryPicker() }
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -11,6 +11,7 @@ import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.model.deepCopy
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator
@ -144,52 +145,56 @@ open class IpnViewModel : ViewModel() {
// Login/Logout
/**
* Order of operations:
* 1. editPrefs() with maskedPrefs (to allow ControlURL override), WantRunning=true,
* LoggedOut=false if AuthKey != null
* 2. start() starts the LocalBackend state machine
* 3. startLoginInteractive() is currently required for bother interactive and non-interactive
* (using auth key) login
*
* Any failure shortcircuits the chain and invokes completionHandler once.
*/
fun login(
maskedPrefs: Ipn.MaskedPrefs? = null,
authKey: String? = null,
completionHandler: (Result<Unit>) -> Unit = {}
) {
val client = Client(viewModelScope)
val loginAction = {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { TSLog.d(TAG, "Login started: $it") }
.onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
val finalMaskedPrefs = maskedPrefs?.deepCopy() ?: Ipn.MaskedPrefs()
finalMaskedPrefs.WantRunning = true
if (authKey != null) {
finalMaskedPrefs.LoggedOut = false
}
// Need to stop running before logging in to clear routes:
// https://linear.app/tailscale/issue/ENG-3441/routesdns-is-not-cleared-when-switching-profiles-or-reauthenticating
val stopThenLogin = {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { loginAction() }
.onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
client.editPrefs(finalMaskedPrefs) { editResult ->
editResult
.onFailure {
TSLog.e(TAG, "editPrefs() failed: ${it.message}")
completionHandler(Result.failure(it))
}
.onSuccess {
val opts = Ipn.Options(UpdatePrefs = editResult.getOrThrow(), AuthKey = authKey)
client.start(opts) { startResult ->
startResult
.onFailure {
TSLog.e(TAG, "start() failed: ${it.message}")
completionHandler(Result.failure(it))
}
.onSuccess {
client.startLoginInteractive { loginResult ->
loginResult
.onFailure {
TSLog.e(TAG, "startLoginInteractive() failed: ${it.message}")
completionHandler(Result.failure(it))
}
.onSuccess { completionHandler(Result.success(Unit)) }
}
val startAction = {
Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start ->
start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() }
}
}
// If an MDM control URL is set, we will always use that in lieu of anything the user sets.
var prefs = maskedPrefs
val mdmControlURL = MDMSettings.loginURL.flow.value.value
if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs()
prefs.ControlURL = mdmControlURL
TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
}
prefs?.let {
Client(viewModelScope).editPrefs(it) { result ->
result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() }
}
} ?: run { startAction() }
}
fun loginWithAuthKey(authKey: String, completionHandler: (Result<Unit>) -> Unit = {}) {

@ -1,9 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.getValue
@ -25,6 +25,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import java.time.Duration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@ -35,61 +36,55 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T
return MainViewModel(appViewModel) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
@OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
// The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle
private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be
// invoked until the current operation is complete.
var isToggleInProgress = MutableStateFlow(false)
// Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
// Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers
// The list of peers
private val _searchViewPeers = MutableStateFlow<List<PeerSet>>(emptyList())
val searchViewPeers: StateFlow<List<PeerSet>> = _searchViewPeers
// The current state of the IPN for determining view visibility
val ipnState = Notifier.state
// The active search term for filtering peers
private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm
var autoFocusSearch by mutableStateOf(true)
private set
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
// The peer for which the dropdown menu is currently expanded. Null if no menu is expanded
var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared
val isVpnPrepared: StateFlow<Boolean> = appViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
val isVpnActive: StateFlow<Boolean> = appViewModel.vpnActive
var searchJob: Job? = null
@ -115,18 +110,22 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
fun onPingDismissal() {
this.pingViewModel.handleDismissal()
}
// Returns true if we should skip all of the user-interactive permissions prompts
// (with the exception of the VPN permission prompt)
fun skipPromptsForAuthKeyLogin(): Boolean {
val v = MDMSettings.authKey.flow.value.value
return v != null && v != ""
}
private val peerCategorizer = PeerCategorizer()
init {
viewModelScope.launch {
var previousState: State? = null
combine(Notifier.state, isVpnActive) { state, active -> state to active }
.collect { (currentState, active) ->
// Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on
val isOn =
when {
@ -135,15 +134,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
// Update the VPN toggle state
_vpnToggleState.value = isOn
// Update the previous state
previousState = currentState
}
}
viewModelScope.launch {
_searchTerm.debounce(250L).collect { term ->
// run the search as a background task
@ -155,7 +151,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
}
}
viewModelScope.launch {
Notifier.netmap.collect { it ->
it?.let { netmap ->
@ -166,7 +161,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
_peers.value = peerCategorizer.peerSets
_searchViewPeers.value = filteredPeers
}
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
@ -181,20 +175,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
}
}
viewModelScope.launch {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
}
}
fun maybeRequestVpnPermission() {
_requestVpnPermission.value = true
}
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent")
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
vpnViewModel.setVpnPrepared(true)
appViewModel.setVpnPrepared(true)
startVPN()
}
_requestVpnPermission.value = false // reset
}
fun toggleVpn(desiredState: Boolean) {
@ -207,12 +206,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
isToggleInProgress.value = true
try {
val currentState = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value
if (desiredState) {
// User wants to turn ON the VPN
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
currentState != Ipn.State.Running -> startVPN()
}
} else {

@ -0,0 +1,22 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class PermissionsViewModel : ViewModel() {
private val _currentDir =
MutableStateFlow<String?>(TaildropDirectoryStore.loadSavedDir()?.toString())
val currentDir: StateFlow<String?> = _currentDir
fun refreshCurrentDir() {
val newUri = TaildropDirectoryStore.loadSavedDir()?.toString()
TSLog.d("PermissionsViewModel", "refreshCurrentDir: $newUri")
_currentDir.value = newUri
}
}

@ -4,14 +4,18 @@
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.SettingState
import com.tailscale.ipn.ui.util.InstalledApp
import com.tailscale.ipn.ui.util.InstalledAppsManager
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
@ -26,6 +30,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
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())
initSelectedPackageNames()
@ -53,15 +59,23 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
}
fun select(packageName: String) {
if (selectedPackageNames.value.contains(packageName)) {
return
}
if (excludedPackageNames.value.contains(packageName)) return
selectedPackageNames.set(selectedPackageNames.value + packageName)
App.get().addUserSelectedPackage(packageName)
debounceSave()
}
fun deselect(packageName: String) {
selectedPackageNames.set(selectedPackageNames.value - packageName)
App.get().removeUserSelectedPackage(packageName)
debounceSave()
}
private fun debounceSave() {
saveJob?.cancel()
saveJob =
viewModelScope.launch {
delay(500) // Wait to batch multiple rapid updates
App.get().updateUserSelectedPackages(selectedPackageNames.value)
}
}
}

@ -0,0 +1,95 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util
import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import kotlin.random.Random
class NoSuchKeyException : Exception("no key found matching the provided ID")
class HardwareKeysNotSupported : Exception("hardware-backed keys are not supported on this device")
// HardwareKeyStore implements the callbacks necessary to implement key.HardwareAttestationKey on
// the Go side. It uses KeyStore with a StrongBox processor.
class HardwareKeyStore() {
var keyStoreKeys = HashMap<String, KeyPair>();
val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
@OptIn(ExperimentalStdlibApi::class)
fun newID(): String {
var id: String
do {
id = Random.nextBytes(4).toHexString()
} while (keyStoreKeys.containsKey(id))
return id
}
fun createKey(): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
throw HardwareKeysNotSupported()
}
val id = newID()
val kpg: KeyPairGenerator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"
)
val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(
id, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
).run {
// Use DIGEST_NONE because hashing is done on the Go side.
setDigests(KeyProperties.DIGEST_NONE)
setIsStrongBoxBacked(true)
build()
}
kpg.initialize(parameterSpec)
val kp = kpg.generateKeyPair()
keyStoreKeys[id] = kp
return id
}
fun releaseKey(id: String) {
keyStoreKeys.remove(id)
}
fun sign(id: String, data: ByteArray): ByteArray {
val key = keyStoreKeys[id]
if (key == null) {
throw NoSuchKeyException()
}
// Use NONEwithECDSA because hashing is done on the Go side.
return Signature.getInstance("NONEwithECDSA").run {
initSign(key.private)
update(data)
sign()
}
}
fun public(id: String): ByteArray {
val key = keyStoreKeys[id]
if (key == null) {
throw NoSuchKeyException()
}
return key.public.encoded
}
fun load(id: String) {
if (keyStoreKeys[id] != null) {
// Already loaded.
return
}
val entry: KeyStore.Entry = keyStore.getEntry(id, null)
if (entry !is KeyStore.PrivateKeyEntry) {
throw NoSuchKeyException()
}
keyStoreKeys[id] = KeyPair(entry.certificate.publicKey, entry.privateKey)
}
}

@ -0,0 +1,322 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.ui.util.OutputStreamAdapter
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import libtailscale.Libtailscale
import org.json.JSONObject
data class SafFile(val fd: Int, val uri: String)
object ShareFileHelper : libtailscale.ShareFileHelper {
private var appContext: Context? = null
private var app: libtailscale.Application? = null
private var savedUri: String? = null
private var scope: CoroutineScope? = null
@JvmStatic
fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
appContext = context.applicationContext
this.app = app
savedUri = uri
scope = appScope
Libtailscale.setShareFileHelper(this)
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
}
// A simple data class that holds a SAF OutputStream along with its URI.
data class SafStream(val uri: String, val stream: OutputStream)
val taildropPrompt = MutableSharedFlow<Unit>(replay = 1)
fun observeTaildropPrompt(): Flow<Unit> = taildropPrompt
@Volatile private var directoryReady: CompletableDeferred<Unit>? = null
fun hasValidTaildropDir(): Boolean {
val uri = TaildropDirectoryStore.loadSavedDir()
if (uri == null) return false
// Only SAF tree URIs are supported
if (uri.scheme != "content") {
TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}")
return false
}
val context = appContext ?: return false
val docFile = DocumentFile.fromTreeUri(context, uri)
if (docFile == null || !docFile.exists() || !docFile.canWrite()) {
TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri")
return false
}
return true
}
private suspend fun waitUntilTaildropDirReady() {
if (!hasValidTaildropDir()) {
if (directoryReady?.isActive != true) {
directoryReady = CompletableDeferred()
scope?.launch { taildropPrompt.emit(Unit) }
}
directoryReady?.await()
}
}
fun notifyDirectoryReady() {
directoryReady?.takeIf { !it.isCompleted }?.complete(Unit)
}
// A helper function that opens or creates a SafStream for a given file.
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
val context = appContext ?: return "" to null
val dirUri = savedUri ?: return "" to null
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
val file =
dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName)
?: return "" to null
val os = context.contentResolver.openOutputStream(file.uri, "rw")
return file.uri.toString() to os
}
@Throws(IOException::class)
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
val ctx = appContext ?: throw IOException("App context not initialized")
val dirUri = savedUri ?: throw IOException("No directory URI")
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
?: throw IOException("Invalid tree URI: $dirUri")
val file =
dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName)
?: throw IOException("Failed to create file: $fileName")
val pfd =
ctx.contentResolver.openFileDescriptor(file.uri, "rw")
?: throw IOException("Failed to open file descriptor for ${file.uri}")
val fos = FileOutputStream(pfd.fileDescriptor)
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
return file.uri.toString() to SeekableOutputStream(fos, pfd)
}
private val currentUri = ConcurrentHashMap<String, String>()
@Throws(IOException::class)
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
runBlocking { waitUntilTaildropDirReady() }
val (uri, stream) = openWriterFD(fileName, offset)
if (stream == null) {
throw IOException("Failed to open file writer for $fileName")
}
currentUri[fileName] = uri
return OutputStreamAdapter(stream)
}
@Throws(IOException::class)
override fun getFileURI(fileName: String): String {
runBlocking { waitUntilTaildropDirReady() }
currentUri[fileName]?.let {
return it
}
val ctx = appContext ?: throw IOException("App context not initialized")
val dirStr = savedUri ?: throw IOException("No saved directory URI")
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
?: throw IOException("Invalid tree URI: $dirStr")
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
val uri = file.uri.toString()
currentUri[fileName] = uri
return uri
}
@Throws(IOException::class)
override fun renameFile(oldPath: String, targetName: String): String {
val ctx = appContext ?: throw IOException("not initialized")
val dirUri = savedUri ?: throw IOException("directory not set")
val srcUri = Uri.parse(oldPath)
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
?: throw IOException("cannot open dir $dirUri")
var finalName = targetName
dir.findFile(finalName)?.let { existing ->
if (lengthOfUri(ctx, existing.uri) == 0L) {
existing.delete()
} else {
finalName = generateNewFilename(finalName)
}
}
try {
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
cleanupPartials(dir, targetName)
return newUri.toString()
}
} catch (e: Exception) {
TSLog.w(
"renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
}
val dest =
dir.createFile("application/octet-stream", finalName)
?: throw IOException("createFile failed for $finalName")
ctx.contentResolver.openInputStream(srcUri).use { inp ->
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
if (inp == null || out == null) {
dest.delete()
throw IOException("Unable to open output stream for URI: ${dest.uri}")
}
inp.copyTo(out)
}
}
ctx.contentResolver.delete(srcUri, null, null)
cleanupPartials(dir, targetName)
return dest.uri.toString()
}
private fun lengthOfUri(ctx: Context, uri: Uri): Long =
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
// delete any stray “.partial” files for this base name
private fun cleanupPartials(dir: DocumentFile, base: String) {
for (child in dir.listFiles()) {
val n = child.name ?: continue
if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) {
child.delete()
}
}
}
@Throws(IOException::class)
override fun deleteFile(uri: String) {
runBlocking { waitUntilTaildropDirReady() }
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
val uri = Uri.parse(uri)
val doc =
DocumentFile.fromSingleUri(ctx, uri)
?: throw IOException("DeleteFile: cannot resolve URI $uri")
if (!doc.delete()) {
throw IOException("DeleteFile: delete() returned false for $uri")
}
}
@Throws(IOException::class)
override fun getFileInfo(fileName: String): String {
val context = appContext ?: throw IOException("app context not initialized")
val dirUri = savedUri ?: throw IOException("SAF URI not initialized")
val dir =
DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
?: throw IOException("could not resolve SAF root")
val file =
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
val name = file.name ?: throw IOException("file name missing for $fileName")
val size = file.length()
val modTime = file.lastModified()
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
}
private fun jsonEscape(s: String): String {
return JSONObject.quote(s)
}
fun generateNewFilename(filename: String): String {
val dotIndex = filename.lastIndexOf('.')
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
val extension = if (dotIndex != -1) filename.substring(dotIndex) else ""
val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension"
}
fun listPartialFiles(suffix: String): Array<String> {
val context = appContext ?: return emptyArray()
val rootUri = savedUri ?: return emptyArray()
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
return dir.listFiles()
.filter { it.name?.endsWith(suffix) == true }
.mapNotNull { it.name }
.toTypedArray()
}
@Throws(IOException::class)
override fun listFilesJSON(suffix: String): String {
val list = listPartialFiles(suffix)
if (list.isEmpty()) {
throw IOException("no files found matching suffix \"$suffix\"")
}
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
}
@Throws(IOException::class)
override fun openFileReader(name: String): libtailscale.InputStream {
val context = appContext ?: throw IOException("app context not initialized")
val rootUri = savedUri ?: throw IOException("SAF URI not initialized")
val dir =
DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
?: throw IOException("could not open SAF root")
val suffix = name.substringAfterLast('.', ".$name")
val file =
dir.listFiles().firstOrNull {
val fname = it.name ?: return@firstOrNull false
fname.endsWith(suffix, ignoreCase = false)
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
val inStream =
context.contentResolver.openInputStream(file.uri)
?: throw IOException("openInputStream returned null for ${file.uri}")
return InputStreamAdapter(inStream)
}
fun setUri(uri: String) {
savedUri = uri
}
private class SeekableOutputStream(
private val fos: FileOutputStream,
private val pfd: ParcelFileDescriptor
) : OutputStream() {
private var closed = false
override fun write(b: Int) = fos.write(b)
override fun write(b: ByteArray) = fos.write(b)
override fun write(b: ByteArray, off: Int, len: Int) {
fos.write(b, off, len)
}
override fun close() {
if (!closed) {
closed = true
try {
fos.flush()
fos.fd.sync() // blocks until data + metadata are durable
} finally {
fos.close()
pfd.close()
}
}
}
override fun flush() = fos.flush()
}
}

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:tint="#000000">
<!-- Folder outline -->
<path
android:fillColor="@android:color/white"
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z" />
<!-- Flipped arrow, shifted downward by 1dp (in viewport units) -->
<group
android:translateY="2">
<path
android:fillColor="@android:color/white"
android:pathData="M16,10.99l-1.41,-1.41L13,11.16V7h-2v4.16L9.41,9.58 8,10.99 12.01,15 16,10.99z" />
</group>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17L8,17v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
</vector>

@ -24,6 +24,10 @@
<string name="no_results">No results</string>
<string name="back">Back</string>
<string name="clear_search">Clear search</string>
<string name="off">Off</string>
<string name="on">On</string>
<string name="try_again">Try again</string>
<string name="cancel">Cancel</string>
<!-- Strings for the about screen -->
<string name="app_name" translatable="false">Tailscale</string>
@ -132,6 +136,19 @@
<string name="invalidAuthKeyTitle">Invalid key</string>
<string name="custom_control_url_title">Custom control server URL</string>
<string name="auth_key_input_title">Auth key</string>
<string name="delete_tailnet">Delete tailnet</string>
<string name="contact_support">Contact support</string>
<string name="request_deletion_nonowner">All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist.</string>
<string name="request_deletion_owner_part1">
As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to
</string>
<string name="request_deletion_owner_part2a">
and look for “Delete tailnet”.
</string>
<string name="request_deletion_owner_part2b">
All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist.
</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose exit node</string>
@ -203,6 +220,8 @@
<string name="hides_the_specified_categories_of_network_devices_from_the_devices_list_in_the_client">Hides the specified categories of network devices from the devices list in the client.</string>
<string name="allow_lan_access_when_using_an_exit_node">Allow LAN access when using an exit node</string>
<string name="enable_posture_checking">Enable posture checking</string>
<string name="device_serial_number">Serial number of the device that is running Tailscale</string>
<string name="device_serial_number_descr">Allows administrators to pass the serial number of the device to Tailscale client using MDM.</string>
<string name="use_tailscale_dns_settings">Use Tailscale DNS settings</string>
<string name="use_tailscale_subnets">Use Tailscale subnets</string>
<string name="allow_incoming_connections">Allow incoming connections</string>
@ -214,6 +233,8 @@
<string name="run_as_exit_node_visibility">Run as exit node visibility</string>
<string name="defines_an_auth_key_that_will_be_used_for_login">Defines an auth key that will be used for login.</string>
<string name="auth_key">Auth Key</string>
<string name="skips_the_intro_page_shown_to_users_that_open_the_app_for_the_first_time">Skips the intro page shown to users that open the app for the first time</string>
<string name="onboarding_flow">Skip the Onboarding Flow</string>
<!-- Permissions Management -->
<string name="permissions">Permissions</string>
@ -221,7 +242,13 @@
<string name="permission_write_external_storage_needed">We use storage in order to receive files with Taildrop.</string>
<string name="permission_post_notifications">Notifications</string>
<string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings. </string>
<string name="open_notification_settings">Go to notification settings</string>
<string name="notification_settings_explanation">Persistent status notifications are off by default and can be enabled in system settings.</string>
<string name="taildrop_dir">Taildrop directory</string>
<string name="taildrop_dir_access">Taildrop directory access</string>
<string name="permission_taildrop_dir">Give Tailscale access to a folder in order to be able to download incoming files sent to you via Taildrop.</string>
<string name="dir_access">Directory access</string>
<string name="pick_dir">Pick a different directory</string>
<!-- Strings for the share activity -->
<string name="share">Send with Taildrop</string>
@ -311,7 +338,6 @@
<string name="vpn_permission_denied">VPN permission denied</string>
<string name="multiple_vpn_explainer">Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN.</string>
<string name="go_to_settings">Go to Settings</string>
<string name="cancel">Cancel</string>
<string name="subnet_routes">Subnet routes</string>
<string name="run_as_subnet_router_header">Advertise routes to machines that are not running Tailscale to make them available in your tailnet. Routes must be approved in the admin console.</string>
<string name="open_kb_article">Open KB Article</string>
@ -331,4 +357,14 @@
<string name="hostname">Hostname</string>
<string name="failed_to_save">Failed to save</string>
<!-- Strings for fallback VPN dialog -->
<string name="vpn_permission_needed">VPN permission needed</string>
<string name="vpn_explainer">Tailscale needs VPN access, but it looks like your device may not show VPN settings. If you are using another VPN app or have work policies, disable them first, then try again.</string>
<!-- Strings for the taildrop directory picker interstitial -->
<string name="taildrop_directory_picker_title">Taildrop Directory</string>
<string name="taildrop_directory_picker_body">You have not selected a directory for incoming taildrop transfers. Please select or create a target directory.</string>
<string name="taildrop_directory_picker_info">What is taildrop?</string>
<string name="taildrop_directory_picker_button">Open Directory Picker</string>
</resources>

@ -66,6 +66,12 @@
android:restrictionType="choice"
android:title="@string/enable_posture_checking" />
<restriction
android:description="@string/device_serial_number_descr"
android:key="DeviceSerialNumber"
android:restrictionType="string"
android:title="@string/device_serial_number" />
<restriction
android:entries="@array/always_never_userdecides_labels"
android:entryValues="@array/always_never_userdecides"
@ -134,4 +140,12 @@
android:key="Hostname"
android:restrictionType="string"
android:title="@string/hostname" />
<restriction
android:description="@string/skips_the_intro_page_shown_to_users_that_open_the_app_for_the_first_time"
android:entries="@array/show_hide_labels"
android:entryValues="@array/show_hide"
android:key="OnboardingFlow"
android:restrictionType="choice"
android:title="@string/onboarding_flow" />
</restrictions>

@ -1,11 +1,11 @@
module github.com/tailscale/tailscale-android
go 1.24.0
go 1.25.1
require (
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b
tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee
)
require (
@ -33,16 +33,15 @@ require (
github.com/djherbis/times v1.6.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-tpm v0.9.4 // indirect
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/illarion/gonotify/v3 v3.0.2 // indirect
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
@ -69,20 +68,20 @@ require (
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.10.0 // indirect
golang.org/x/tools v0.30.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.33.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect

@ -1,11 +1,17 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk=
github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
@ -40,6 +46,10 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -49,8 +59,6 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@ -59,10 +67,12 @@ github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -71,16 +81,14 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
@ -99,6 +107,8 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -119,6 +129,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -149,17 +161,21 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U=
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
@ -168,37 +184,39 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k=
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab/go.mod h1:udWezQGYjqrCxz5nV321pXQTx5oGbZx+khZvFjZNOPM=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
@ -211,9 +229,11 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I=
honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b h1:KfTHlSLdZd2XhZQ0t6C2AXu9/QxmZR/eXfHOzOnQ3V8=
tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b/go.mod h1:CCR2Ti9anln7NMAbpSoECkd5P4N80OhjneOQ/GarSBE=
tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee h1:BCJ6ux5S7jSv8OkbUHUISyKso+m5VMf9zJ6mQAsBZ+s=
tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw=

@ -1 +1 @@
982da8f24fa0504f2214f24b0d68b2febd5983f8
aa85d1541af0921f830f053f29d91971fa5838f6

@ -18,6 +18,7 @@ import (
"tailscale.com/drive/driveimpl"
_ "tailscale.com/feature/condregister"
"tailscale.com/feature/taildrop"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
@ -33,6 +34,7 @@ import (
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
"tailscale.com/util/eventbus"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/wgengine/router"
@ -41,19 +43,21 @@ import (
type App struct {
dataDir string
// enables direct file mode for the taildrop manager
// passes along SAF file information for the taildrop manager
directFileRoot string
shareFileHelper ShareFileHelper
// appCtx is a global reference to the com.tailscale.ipn.App instance.
appCtx AppContext
store *stateStore
policyStore *syspolicyHandler
policyStore *syspolicyStore
logIDPublicAtomic atomic.Pointer[logid.PublicID]
localAPIHandler http.Handler
backend *ipnlocal.LocalBackend
ready sync.WaitGroup
backendMu sync.Mutex
}
func start(dataDir, directFileRoot string, appCtx AppContext) Application {
@ -96,6 +100,8 @@ type backend struct {
logIDPublic logid.PublicID
logger *logtail.Logger
bus *eventbus.Bus
// avoidEmptyDNS controls whether to use fallback nameservers
// when no nameservers are provided by Tailscale.
avoidEmptyDNS bool
@ -121,7 +127,7 @@ func (a *App) runBackend(ctx context.Context) error {
}
configs := make(chan configPair)
configErrs := make(chan error)
b, err := a.newBackend(a.dataDir, a.directFileRoot, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
b, err := a.newBackend(a.dataDir, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
if rcfg == nil {
return nil
}
@ -135,7 +141,14 @@ func (a *App) runBackend(ctx context.Context) error {
a.backend = b.backend
defer b.CloseTUNs()
h := localapi.NewHandler(ipnauth.Self, b.backend, log.Printf, *a.logIDPublicAtomic.Load())
hc := localapi.HandlerConfig{
Actor: ipnauth.Self,
Backend: b.backend,
Logf: log.Printf,
LogID: *a.logIDPublicAtomic.Load(),
EventBus: b.bus,
}
h := localapi.NewHandler(hc)
h.PermitRead = true
h.PermitWrite = true
a.localAPIHandler = h
@ -238,10 +251,10 @@ func (a *App) runBackend(ctx context.Context) error {
}
}
func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, store *stateStore,
func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
settings settingsFunc) (*backend, error) {
sys := new(tsd.System)
sys := tsd.NewSystem()
sys.Set(store)
logf := logger.RusagePrefixLog(log.Printf)
@ -249,7 +262,9 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
devices: newTUNDevices(),
settings: settings,
appCtx: appCtx,
bus: eventbus.New(),
}
var logID logid.PrivateID
logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000"))
storedLogID, err := store.read(logPrefKey)
@ -268,7 +283,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
logID.UnmarshalText([]byte(storedLogID))
}
netMon, err := netmon.New(logf)
netMon, err := netmon.New(b.bus, logf)
if err != nil {
log.Printf("netmon.New: %w", err)
}
@ -290,6 +305,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
DriveForLocal: driveimpl.NewFileSystemForLocal(logf),
EventBus: sys.Bus.Get(),
})
if err != nil {
return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err)
@ -308,12 +324,14 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
w.Start()
}
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetFileOps(newAndroidFileOps(a.shareFileHelper))
}
if err != nil {
engine.Close()
return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err)
}
lb.SetDirectFileRoot(directFileRoot)
if err := ns.Start(lb); err != nil {
return nil, fmt.Errorf("startNetstack: %w", err)
}
@ -334,6 +352,16 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
return b, nil
}
func (a *App) watchFileOpsChanges() {
for {
select {
case helper := <-onShareFileHelper:
log.Printf("Got ShareFileHelper")
a.shareFileHelper = helper
}
}
}
func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore")

@ -23,6 +23,9 @@ var (
// onLog receives Android logs to be sent to the logger
onLog = make(chan string, 10)
// onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework
onShareFileHelper = make(chan ShareFileHelper, 1)
)
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

@ -0,0 +1,100 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import (
"encoding/json"
"io"
"os"
"time"
"tailscale.com/feature/taildrop"
)
// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper.
type androidFileOps struct {
helper ShareFileHelper
}
var _ taildrop.FileOps = (*androidFileOps)(nil)
func newAndroidFileOps(helper ShareFileHelper) *androidFileOps {
return &androidFileOps{helper: helper}
}
func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
wc, err := ops.helper.OpenFileWriter(name, offset)
if err != nil {
return nil, "", err
}
uri, err := ops.helper.GetFileURI(name)
if err != nil {
wc.Close()
return nil, "", err
}
return wc, uri, nil
}
func (ops *androidFileOps) Remove(baseName string) error {
uri, err := ops.helper.GetFileURI(baseName)
if err != nil {
return err
}
return ops.helper.DeleteFile(uri)
}
func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) {
return ops.helper.RenameFile(oldPath, newName)
}
func (ops *androidFileOps) ListFiles() ([]string, error) {
namesJSON, err := ops.helper.ListFilesJSON("")
if err != nil {
return nil, err
}
var names []string
if err := json.Unmarshal([]byte(namesJSON), &names); err != nil {
return nil, err
}
return names, nil
}
func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) {
in, err := ops.helper.OpenFileReader(name)
if err != nil {
return nil, err
}
return adaptInputStream(in), nil
}
func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) {
infoJSON, err := ops.helper.GetFileInfo(name)
if err != nil {
return nil, err
}
var fi androidFileInfo
if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil {
return nil, err
}
return &fi, nil
}
type androidFileInfoJSON struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime int64 `json:"modTime"`
}
type androidFileInfo struct {
data androidFileInfoJSON
}
// compile-time check
var _ os.FileInfo = (*androidFileInfo)(nil)
func (fi *androidFileInfo) Name() string { return fi.data.Name }
func (fi *androidFileInfo) Size() int64 { return fi.data.Size }
func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 }
func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) }
func (fi *androidFileInfo) IsDir() bool { return false }
func (fi *androidFileInfo) Sys() any { return nil }

@ -29,6 +29,10 @@ type AppContext interface {
// at the given key, or returns empty string if unset.
DecryptFromPref(key string) (string, error)
// GetStateStoreKeysJson retrieves all keys stored in the encrypted SharedPreferences,
// strips off the "statestore-" prefix, and returns them as a JSON array.
GetStateStoreKeysJSON() string
// GetOSVersion gets the Android version.
GetOSVersion() (string, error)
@ -61,6 +65,15 @@ type AppContext interface {
// GetSyspolicyStringArrayValue returns the current string array value for the given system policy,
// expressed as a JSON string.
GetSyspolicyStringArrayJSONValue(key string) (string, error)
// Methods used to implement key.HardwareAttestationKey using the Android
// KeyStore.
HardwareAttestationKeySupported() bool
HardwareAttestationKeyCreate() (id string, err error)
HardwareAttestationKeyRelease(id string) error
HardwareAttestationKeyPublic(id string) (pub []byte, err error)
HardwareAttestationKeySign(id string, data []byte) (sig []byte, err error)
HardwareAttestationKeyLoad(id string) error
}
// IPNService corresponds to our IPNService in Java.
@ -162,6 +175,47 @@ type InputStream interface {
Close() error
}
// OutputStream provides an adapter between Java's OutputStream and Go's
// io.WriteCloser.
type OutputStream interface {
Write([]byte) (int, error)
Close() error
}
// ShareFileHelper corresponds to the Kotlin ShareFileHelper class
type ShareFileHelper interface {
// OpenFileWriter creates or truncates a file named fileName at a given offset,
// returning an OutputStream for writing. Returns an error if the file cannot be opened.
OpenFileWriter(fileName string, offset int64) (stream OutputStream, err error)
// GetFileURI returns the SAF URI string for the file named fileName,
// or an error if the file cannot be resolved.
GetFileURI(fileName string) (uri string, err error)
// RenameFile renames the file at oldPath (a SAF URI) into the Taildrop directory,
// giving it the new targetName. Returns the SAF URI of the renamed file, or an error.
RenameFile(oldPath string, targetName string) (newURI string, err error)
// ListFilesJSON returns a JSON-encoded list of filenames in the Taildrop directory
// that end with the specified suffix. If the suffix is empty, it returns all files.
// Returns an error if no matching files are found or the directory cannot be accessed.
ListFilesJSON(suffix string) (json string, err error)
// OpenFileReader opens the file with the given name (typically a .partial file)
// and returns an InputStream for reading its contents.
// Returns an error if the file cannot be opened.
OpenFileReader(name string) (stream InputStream, err error)
// DeleteFile deletes the file identified by the given SAF URI string.
// Returns an error if the file could not be deleted.
DeleteFile(uri string) error
// GetFileInfo returns a JSON-encoded string containing metadata for fileName,
// matching the fields of androidFileInfo (name, size, modTime).
// Returns an error if the file does not exist or cannot be accessed.
GetFileInfo(fileName string) (json string, err error)
}
// The below are global callbacks that allow the Java application to notify Go
// of various state changes.
@ -182,3 +236,19 @@ func SendLog(logstr []byte) {
log.Printf("Log %v not sent", logstr) // missing argument in original code
}
}
func SetShareFileHelper(fileHelper ShareFileHelper) {
// Drain the channel if there's an old value.
select {
case <-onShareFileHelper:
default:
// Channel was already empty.
}
select {
case onShareFileHelper <- fileHelper:
default:
// In the unlikely case the channel is still full, drain it and try again.
<-onShareFileHelper
onShareFileHelper <- fileHelper
}
}

@ -0,0 +1,91 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import (
"crypto"
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"tailscale.com/types/key"
)
func emptyHardwareAttestationKey(appCtx AppContext) key.HardwareAttestationKey {
return &hardwareAttestationKey{appCtx: appCtx}
}
func createHardwareAttestationKey(appCtx AppContext) (key.HardwareAttestationKey, error) {
id, err := appCtx.HardwareAttestationKeyCreate()
if err != nil {
return nil, err
}
k := &hardwareAttestationKey{appCtx: appCtx, id: id}
if err := k.fetchPublic(); err != nil {
return nil, err
}
return k, nil
}
var hardwareAttestationKeyNotInitialized = errors.New("HardwareAttestationKey has not been initialized")
type hardwareAttestationKey struct {
appCtx AppContext
id string
// public key is always initialized in createHardwareAttestationKey and
// UnmarshalJSON. It's only nil in emptyHardwareAttestationKey.
public *ecdsa.PublicKey
}
func (k *hardwareAttestationKey) fetchPublic() error {
if k.id == "" || k.appCtx == nil {
return hardwareAttestationKeyNotInitialized
}
pubRaw, err := k.appCtx.HardwareAttestationKeyPublic(k.id)
if err != nil {
return fmt.Errorf("loading public key from KeyStore: %w", err)
}
pubAny, err := x509.ParsePKIXPublicKey(pubRaw)
if err != nil {
return fmt.Errorf("parsing public key: %w", err)
}
pub, ok := pubAny.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("parsed key is %T, expected *ecdsa.PublicKey", pubAny)
}
k.public = pub
return nil
}
func (k *hardwareAttestationKey) Public() crypto.PublicKey { return k.public }
func (k *hardwareAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
if k.id == "" || k.appCtx == nil {
return nil, hardwareAttestationKeyNotInitialized
}
return k.appCtx.HardwareAttestationKeySign(k.id, digest)
}
func (k *hardwareAttestationKey) MarshalJSON() ([]byte, error) { return json.Marshal(k.id) }
func (k *hardwareAttestationKey) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &k.id); err != nil {
return err
}
if err := k.appCtx.HardwareAttestationKeyLoad(k.id); err != nil {
return fmt.Errorf("loading key with ID %q from KeyStore: %w", k.id, err)
}
return k.fetchPublic()
}
func (k *hardwareAttestationKey) Close() error {
if k.id == "" || k.appCtx == nil {
return hardwareAttestationKeyNotInitialized
}
return k.appCtx.HardwareAttestationKeyRelease(k.id)
}

@ -230,27 +230,6 @@ func (r *Response) Flush() {
})
}
func adaptInputStream(in InputStream) io.ReadCloser {
if in == nil {
return nil
}
r, w := io.Pipe()
go func() {
defer w.Close()
for {
b, err := in.Read()
if err != nil {
log.Printf("error reading from inputstream: %s", err)
}
if b == nil {
return
}
w.Write(b)
}
}()
return r
}
// Below taken from Go stdlib
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")

@ -101,9 +101,20 @@ func (a *App) getInterfaces() ([]netmon.Interface, error) {
addrs := strings.Trim(fields[1], " \n")
for _, addr := range strings.Split(addrs, " ") {
_, ipnet, err := net.ParseCIDR(addr)
pfx, err := netip.ParsePrefix(addr)
var ip net.IP
if pfx.Addr().Is4() {
v4 := pfx.Addr().As4()
ip = net.IP(v4[:])
} else {
v6 := pfx.Addr().As16()
ip = net.IP(v6[:])
}
if err == nil {
newIf.AltAddrs = append(newIf.AltAddrs, ipnet)
newIf.AltAddrs = append(newIf.AltAddrs, &net.IPAddr{
IP: ip,
Zone: pfx.Addr().Zone(),
})
}
}

@ -5,6 +5,8 @@ package libtailscale
import (
"encoding/base64"
"encoding/json"
"iter"
"tailscale.com/ipn"
)
@ -23,6 +25,28 @@ func newStateStore(appCtx AppContext) *stateStore {
}
}
func (s *stateStore) All() iter.Seq2[ipn.StateKey, []byte] {
rawJSON := s.appCtx.GetStateStoreKeysJSON()
var keys []string
if err := json.Unmarshal([]byte(rawJSON), &keys); err != nil {
return func(yield func(ipn.StateKey, []byte) bool) {}
}
return func(yield func(ipn.StateKey, []byte) bool) {
for _, k := range keys {
blob, err := s.ReadState(ipn.StateKey(k))
if err != nil {
continue
}
if !yield(ipn.StateKey(k), blob) {
return
}
}
}
}
// compile-time assertion that store must implement ipn.StateStore to give immediate feedback on interface drift.
var _ ipn.StateStore = (*stateStore)(nil)
func prefKeyFor(id ipn.StateKey) string {
return "statestore-" + string(id)
}

@ -0,0 +1,36 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import (
"io"
"log"
)
// adaptInputStream wraps an [InputStream] into an [io.ReadCloser].
// It launches a goroutine to stream reads into a pipe.
func adaptInputStream(in InputStream) io.ReadCloser {
if in == nil {
return nil
}
r, w := io.Pipe()
go func() {
defer w.Close()
for {
b, err := in.Read()
if err != nil {
log.Printf("error reading from inputstream: %v", err)
return
}
if b == nil {
return
}
if _, err := w.Write(b); err != nil {
log.Printf("error writing to pipe: %v", err)
return
}
}
}()
return r
}

@ -10,33 +10,34 @@ import (
"tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/pkey"
)
// syspolicyHandler is a syspolicy handler for the Android version of the Tailscale client,
// syspolicyStore is a syspolicy Store for the Android version of the Tailscale client,
// which lets the main networking code read values set via the Android RestrictionsManager.
type syspolicyHandler struct {
type syspolicyStore struct {
a *App
mu sync.RWMutex
cbs set.HandleSet[func()]
}
func (h *syspolicyHandler) ReadString(key string) (string, error) {
func (h *syspolicyStore) ReadString(key pkey.Key) (string, error) {
if key == "" {
return "", syspolicy.ErrNoSuchKey
}
retVal, err := h.a.appCtx.GetSyspolicyStringValue(key)
retVal, err := h.a.appCtx.GetSyspolicyStringValue(string(key))
return retVal, translateHandlerError(err)
}
func (h *syspolicyHandler) ReadBoolean(key string) (bool, error) {
func (h *syspolicyStore) ReadBoolean(key pkey.Key) (bool, error) {
if key == "" {
return false, syspolicy.ErrNoSuchKey
}
retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(key)
retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(string(key))
return retVal, translateHandlerError(err)
}
func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) {
func (h *syspolicyStore) ReadUInt64(key pkey.Key) (uint64, error) {
if key == "" {
return 0, syspolicy.ErrNoSuchKey
}
@ -44,11 +45,11 @@ func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) {
return 0, errors.New("ReadUInt64 is not implemented on Android")
}
func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) {
func (h *syspolicyStore) ReadStringArray(key pkey.Key) ([]string, error) {
if key == "" {
return nil, syspolicy.ErrNoSuchKey
}
retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(key)
retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(string(key))
if err := translateHandlerError(err); err != nil {
return nil, err
}
@ -63,7 +64,7 @@ func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) {
return arr, err
}
func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(), err error) {
func (h *syspolicyStore) RegisterChangeCallback(cb func()) (unregister func(), err error) {
h.mu.Lock()
handle := h.cbs.Add(cb)
h.mu.Unlock()
@ -74,7 +75,7 @@ func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(),
}, nil
}
func (h *syspolicyHandler) notifyChanged() {
func (h *syspolicyStore) notifyChanged() {
h.mu.RLock()
for _, cb := range h.cbs {
go cb()

@ -16,10 +16,12 @@ import (
"tailscale.com/logtail"
"tailscale.com/logtail/filch"
"tailscale.com/net/netmon"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/util/clientmetric"
"tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/syspolicy/setting"
)
const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go
@ -39,9 +41,19 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a.ready.Add(2)
a.store = newStateStore(a.appCtx)
a.policyStore = &syspolicyHandler{a: a}
a.policyStore = &syspolicyStore{a: a}
netmon.RegisterInterfaceGetter(a.getInterfaces)
syspolicy.RegisterHandler(a.policyStore)
rsop.RegisterStore("DeviceHandler", setting.DeviceScope, a.policyStore)
if appCtx.HardwareAttestationKeySupported() {
key.RegisterHardwareAttestationKeyFns(
func() key.HardwareAttestationKey { return emptyHardwareAttestationKey(appCtx) },
func() (key.HardwareAttestationKey, error) { return createHardwareAttestationKey(appCtx) },
)
} else {
log.Printf("HardwareAttestationKey is not supported on this device")
}
go a.watchFileOpsChanges()
go func() {
defer func() {
if p := recover(); p != nil {

Loading…
Cancel
Save