Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ libtailscale-sources.jar
.DS_Store

tailscale.version

# .tmp folder
.tmp/
6 changes: 6 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tailscale" android:host="navigate" />
</intent-filter>
</activity>
<activity
android:name="ShareActivity"
Expand Down
36 changes: 36 additions & 0 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.DeepLinkNavigator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.AboutView
Expand Down Expand Up @@ -114,6 +115,8 @@ class MainActivity : ComponentActivity() {
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
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()

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
105 changes: 105 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/util/DeepLinkNavigator.kt
Original file line number Diff line number Diff line change
@@ -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<String>): 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
Comment thread
willh-ts marked this conversation as resolved.
}
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")
}
}
}
94 changes: 94 additions & 0 deletions scripts/deeplink-probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Command deeplink-probe fires a tailscale://navigate/<path> 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)
}
Loading