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) +}