diff --git a/.gitignore b/.gitignore
index 20335fdfc3..80b7b5b1db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,6 @@ libtailscale-sources.jar
.DS_Store
tailscale.version
+
+# .tmp folder
+.tmp/
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 9633e2b2bd..f5e4f1a272 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -57,6 +57,12 @@
+
+
+
+
+
+
private lateinit var appViewModel: AppViewModel
private lateinit var viewModel: MainViewModel
+ private var deepLinkNavigator: DeepLinkNavigator? = null
+ private var pendingDeepLink: Uri? = null
val permissionsViewModel: PermissionsViewModel by viewModels()
@@ -136,6 +139,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ pendingDeepLink = deepLinkUri(intent)
+
// grab app to make sure it initializes
App.get()
appViewModel = (application as App).getAppScopedViewModel()
@@ -253,6 +258,20 @@ class MainActivity : ComponentActivity() {
navController = rememberNavController()
+ val introNotYetViewed = remember { isIntroScreenViewedSet() }
+
+ LaunchedEffect(navController) {
+ val nav = DeepLinkNavigator(navController)
+ deepLinkNavigator = nav
+ pendingDeepLink?.let {
+ pendingDeepLink = null
+ when {
+ introNotYetViewed -> TSLog.d(TAG, "Deep link dropped, intro not viewed: $it")
+ !nav.handle(it) -> TSLog.d(TAG, "Deep link handler returned false for $it")
+ }
+ }
+ }
+
AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
@@ -483,6 +502,23 @@ class MainActivity : ComponentActivity() {
}
}
}
+ deepLinkUri(intent)?.let { uri ->
+ val nav = deepLinkNavigator
+ if (nav != null) {
+ if (!nav.handle(uri)) {
+ TSLog.d(TAG, "Deep link handler returned false for $uri")
+ }
+ } else {
+ pendingDeepLink = uri
+ }
+ }
+ }
+
+ private fun deepLinkUri(intent: Intent?): Uri? {
+ if (intent?.action != Intent.ACTION_VIEW) return null
+ val uri = intent.data ?: return null
+ if (uri.scheme != "tailscale" || uri.host != "navigate") return null
+ return uri
}
private fun login(urlString: String) {
diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/DeepLinkNavigator.kt b/android/src/main/java/com/tailscale/ipn/ui/util/DeepLinkNavigator.kt
new file mode 100644
index 0000000000..ccd3cf6500
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/util/DeepLinkNavigator.kt
@@ -0,0 +1,105 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.util
+
+import android.net.Uri
+import androidx.navigation.NavHostController
+import com.tailscale.ipn.ui.model.Tailcfg
+import com.tailscale.ipn.ui.notifier.Notifier
+import com.tailscale.ipn.util.TSLog
+
+// Deep links must only navigate — never perform an action on the user's
+// behalf. Tails whose leaf would imply an action (e.g., a specific exit
+// node) are accepted for URL compatibility but resolve to the parent
+// view; the user picks and confirms in-app.
+class DeepLinkNavigator(private val navController: NavHostController) {
+ companion object {
+ private const val TAG = "DeepLinkNavigator"
+ }
+
+ fun handle(uri: Uri): Boolean {
+ if (uri.host != "navigate") return false
+ val segments = uri.pathSegments
+ val window = segments.firstOrNull() ?: return false
+ val tail = segments.drop(1)
+
+ return when (window) {
+ "main" -> handleMain(tail)
+ "settings" -> {
+ showSettings()
+ true
+ }
+ else -> false
+ }
+ }
+
+ private fun handleMain(tail: List): Boolean {
+ val tab = tail.firstOrNull() ?: return false
+ val rest = tail.drop(1)
+
+ return when (tab) {
+ "devices" ->
+ when (rest.size) {
+ 0 -> {
+ showDeviceList()
+ true
+ }
+ 1 -> pushDeviceDetail(rest[0])
+ else -> false
+ }
+ "exit-nodes" ->
+ when {
+ rest.size <= 1 || (rest.size == 2 && rest[0] == "location") -> {
+ showExitNodePicker()
+ true
+ }
+ else -> false
+ }
+ else -> false
+ }
+ }
+
+ private fun showDeviceList() {
+ popToMain()
+ }
+
+ private fun pushDeviceDetail(identifier: String): Boolean {
+ val node = findNode(identifier)
+ if (node == null) {
+ TSLog.d(TAG, "Deep link: device not found for '$identifier'")
+ return false
+ }
+ navigateOverMain("peerDetails/${node.StableID}")
+ return true
+ }
+
+ private fun findNode(identifier: String): Tailcfg.Node? {
+ val netmap = Notifier.netmap.value ?: return null
+ val all = netmap.Peers.orEmpty() + netmap.SelfNode
+ return all.firstOrNull { identifier.equals(it.ComputedName, ignoreCase = true) }
+ ?: all.firstOrNull { it.StableID == identifier }
+ }
+
+ private fun showExitNodePicker() {
+ navigateOverMain("exitNodes")
+ }
+
+ private fun showSettings() {
+ navigateOverMain("settings")
+ }
+
+ private fun navigateOverMain(route: String) {
+ navController.navigate(route) {
+ popUpTo("main") { inclusive = false }
+ launchSingleTop = true
+ }
+ }
+
+ private fun popToMain() {
+ val popped = navController.popBackStack(route = "main", inclusive = false)
+ if (!popped) {
+ TSLog.d(TAG, "Deep link: popBackStack to 'main' returned false")
+ }
+ }
+}
diff --git a/scripts/deeplink-probe.go b/scripts/deeplink-probe.go
new file mode 100644
index 0000000000..1854586e4c
--- /dev/null
+++ b/scripts/deeplink-probe.go
@@ -0,0 +1,94 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Command deeplink-probe fires a tailscale://navigate/ URI at an
+// attached device three ways (BROWSABLE implicit, bare implicit, explicit
+// component) and tails the relevant logcat lines so you can confirm
+// DeepLinkNavigator saw the intent.
+package main
+
+import (
+ "bufio"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const defaultPath = "main/devices"
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: %s [path-after-navigate]\n", filepath.Base(os.Args[0]))
+ fmt.Fprintf(os.Stderr, " default path: %s\n", defaultPath)
+ }
+ flag.Parse()
+
+ tail := defaultPath
+ if flag.NArg() >= 1 {
+ tail = flag.Arg(0)
+ }
+ uri := "tailscale://navigate/" + strings.TrimPrefix(tail, "/")
+
+ logPath := filepath.Join(os.TempDir(), "deeplink-probe.log")
+ logFile, err := os.Create(logPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "cannot create %s: %v\n", logPath, err)
+ os.Exit(1)
+ }
+ defer logFile.Close()
+
+ tee := io.MultiWriter(os.Stdout, logFile)
+
+ fmt.Printf("URI: %s\nLogging to %s\n\n", uri, logPath)
+
+ if err := exec.Command("adb", "logcat", "-c").Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "adb logcat -c failed: %v\n", err)
+ }
+
+ fires := []struct {
+ label string
+ shell string
+ }{
+ {"implicit (BROWSABLE)", "am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d '" + uri + "'"},
+ {"implicit (no category)", "am start -W -a android.intent.action.VIEW -d '" + uri + "'"},
+ {"explicit component", "am start -W -n com.tailscale.ipn/.MainActivity -a android.intent.action.VIEW -d '" + uri + "'"},
+ }
+ for _, f := range fires {
+ fmt.Fprintf(tee, "=== %s ===\n", f.label)
+ cmd := exec.Command("adb", "shell", f.shell)
+ cmd.Stdout = tee
+ cmd.Stderr = tee
+ if err := cmd.Run(); err != nil {
+ fmt.Fprintf(tee, "adb failed: %v\n", err)
+ }
+ fmt.Fprintln(tee)
+ }
+
+ time.Sleep(time.Second)
+
+ fmt.Fprintln(tee, "\n=== logcat ===")
+ logCmd := exec.Command("adb", "logcat", "-d", "-v", "brief")
+ logCmd.Stderr = tee
+ logOut, err := logCmd.StdoutPipe()
+ if err != nil {
+ fmt.Fprintf(tee, "adb logcat pipe failed: %v\n", err)
+ } else if err := logCmd.Start(); err != nil {
+ fmt.Fprintf(tee, "adb logcat failed to start: %v\n", err)
+ } else {
+ scanner := bufio.NewScanner(logOut)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.Contains(line, "Main Activity") || strings.Contains(line, "DeepLinkNavigator") {
+ fmt.Fprintln(tee, line)
+ }
+ }
+ _ = logCmd.Wait()
+ }
+
+ fmt.Printf("\nFull output: %s\n", logPath)
+}