Showing preview only (292K chars total). Download the full file or copy to clipboard to get everything.
Repository: ppyyr/tailscale-android
Branch: main
Commit: 6030dd3fb5ef
Files: 43
Total size: 276.5 KB
Directory structure:
gitextract_xph_jeqj/
├── .github/
│ └── workflows/
│ └── main.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── PATENTS
├── README.md
├── android/
│ ├── build.gradle
│ ├── gradle/
│ │ └── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── tailscale/
│ │ │ └── ipn/
│ │ │ ├── App.java
│ │ │ ├── DnsConfig.java
│ │ │ ├── IPNActivity.java
│ │ │ ├── IPNService.java
│ │ │ ├── Peer.java
│ │ │ └── QuickToggleService.java
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ └── ic_tile.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── values/
│ │ ├── ic_launcher_background.xml
│ │ └── strings.xml
│ ├── play/
│ │ └── java/
│ │ └── com/
│ │ └── tailscale/
│ │ └── ipn/
│ │ └── Google.java
│ └── test/
│ └── java/
│ └── com/
│ └── tailscale/
│ └── ipn/
│ └── DnsConfigTest.java
├── cmd/
│ └── tailscale/
│ ├── backend.go
│ ├── callbacks.go
│ ├── main.go
│ ├── multitun.go
│ ├── pprof.go
│ ├── store.go
│ ├── tools.go
│ └── ui.go
├── flake.nix
├── go.mod
├── go.sum
├── jni/
│ └── jni.go
├── metadata/
│ └── en-US/
│ ├── full_description.txt
│ └── short_description.txt
└── version/
└── tailscale-version.sh
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/main.yml
================================================
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the main branch
push:
branches: [ main ]
pull_request:
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3
# Runs a single command using the runners shell
- name: Create Docker container
run: docker build . --file Dockerfile --tag tailscale-android
# Runs a set of commands using the runners shell
- name: build tailscale-fdroid.apk
run: docker run -v /home/runner/work/tailscale-android/tailscale-android:/build/tailscale-android tailscale-android make tailscale-fdroid.apk
- name: Upload a Build Artifact
uses: actions/upload-artifact@v3.0.0
with:
# Artifact name
name: tailscale-fdroid.apk # optional, default is artifact
# A file, directory or wildcard pattern that describes what to upload
path: /home/runner/work/tailscale-android/tailscale-android/tailscale-fdroid.apk
# The desired behavior if no files are found using the provided path.
if-no-files-found: error # optional, default is warn
retention-days: 30 # optional
================================================
FILE: .gitignore
================================================
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build
# The destination for the Go Android archive.
android/libs
# Output files from the Makefile:
tailscale-debug.apk
================================================
FILE: Dockerfile
================================================
# This is a Dockerfile for creating a build environment for
# tailscale-android.
FROM openjdk:8-jdk
# To enable running android tools such as aapt
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y lib32z1 lib32stdc++6
# For Go:
RUN apt-get -y --no-install-recommends install curl gcc
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
RUN apt-get -y install make
RUN mkdir -p BUILD
ENV HOME /build
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
WORKDIR $HOME
RUN mkdir android-sdk
ENV ANDROID_HOME $HOME/android-sdk
WORKDIR $ANDROID_HOME
RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip
RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0 sdk-tools-linux-3859397.zip' | sha256sum -c
RUN unzip sdk-tools-linux-3859397.zip
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-31'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;23.1.7779620'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3'
ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
ENV ANDROID_SDK_ROOT /build/android-sdk
# We need some version of Go new enough to support the "embed" package
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
# version we need later, but otherwise this toolchain isn't used:
RUN curl -L https://go.dev/dl/go1.17.5.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN ln -s /usr/local/go/bin/go /usr/bin
RUN mkdir -p $HOME/tailscale-android
WORKDIR $HOME/tailscale-android
# Preload Gradle
COPY android/gradlew android/gradlew
COPY android/gradle android/gradle
RUN ./android/gradlew
CMD /bin/bash
================================================
FILE: LICENSE
================================================
Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Tailscale Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: Makefile
================================================
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
AAR=android/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11)
VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV)
# Extract the long version build.gradle's versionName and strip quotes.
VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle)))
# Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle.
VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)
TOOLCHAINREV=$(shell go run tailscale.com/cmd/printdep --go)
TOOLCHAINDIR=${HOME}/.cache/tailscale-android-go-$(TOOLCHAINREV)
TOOLCHAINSUM=$(shell $(TOOLCHAINDIR)/go/bin/go version >/dev/null && echo "okay" || echo "bad")
TOOLCHAINWANT=okay
export PATH := $(TOOLCHAINDIR)/go/bin:$(PATH)
export GOROOT := # Unset
all: $(APK)
tag_release:
sed -i'.bak' 's/versionCode [[:digit:]]\+/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle
git commit -sm "android: bump version code" android/build.gradle
git tag -a "$(VERSION_LONG)"
bumposs: toolchain
GOPROXY=direct go get tailscale.com@main
go mod tidy -compat=1.17
toolchain:
ifneq ($(TOOLCHAINWANT),$(TOOLCHAINSUM))
@echo want: $(TOOLCHAINWANT)
@echo got: $(TOOLCHAINSUM)
rm -rf ${HOME}/.cache/tailscale-android-go-*
mkdir -p $(TOOLCHAINDIR)
curl --silent -L $(shell go run tailscale.com/cmd/printdep --go-url) | tar -C $(TOOLCHAINDIR) -zx
endif
$(DEBUG_APK): toolchain
mkdir -p android/libs
go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
(cd android && ./gradlew test assemblePlayDebug)
mv android/build/outputs/apk/play/debug/android-play-debug.apk $@
rundebug: $(DEBUG_APK)
adb install -r $(DEBUG_APK)
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
# tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits.
# This is effectively what the F-Droid build definition produces.
# This is useful for testing on e.g. Amazon Fire Stick devices.
tailscale-fdroid.apk: toolchain
mkdir -p android/libs
go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
(cd android && ./gradlew test assembleFdroidDebug)
mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@
# This target is also used by the F-Droid builder.
release_aar: toolchain
release_aar:
mkdir -p android/libs
go run gioui.org/cmd/gogio -ldflags "-X tailscale.com/version.Long=$(VERSIONNAME) -X tailscale.com/version.Short=$(VERSIONNAME_SHORT) -X tailscale.com/version.GitCommit=$(TAILSCALE_COMMIT) -X tailscale.com/version.ExtraGitCommit=$(OUR_VERSION)" -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
$(RELEASE_AAB): release_aar
(cd android && ./gradlew test bundlePlayRelease)
mv ./android/build/outputs/bundle/playRelease/android-play-release.aab $@
release: $(RELEASE_AAB)
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
install: $(DEBUG_APK)
adb install -r $(DEBUG_APK)
dockershell:
docker build -t tailscale-android .
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
clean:
rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR)
.PHONY: all clean install $(DEBUG_APK) $(RELEASE_AAB) release_aar release bump_version dockershell
================================================
FILE: PATENTS
================================================
Additional IP Rights Grant (Patents)
"This implementation" means the copyrightable works distributed by
Tailscale Inc. as part of the Tailscale project.
Tailscale Inc. hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated
in this section) patent license to make, have made, use, offer to
sell, sell, import, transfer and otherwise run, modify and propagate
the contents of this implementation of Tailscale, where such license
applies only to those patent claims, both currently owned or
controlled by Tailscale Inc. and acquired in the future, licensable
by Tailscale Inc. that are necessarily infringed by this
implementation of Tailscale. This grant does not include claims that
would be infringed only as a consequence of further modification of
this implementation. If you or your agent or exclusive licensee
institute or order or agree to the institution of patent litigation
against any entity (including a cross-claim or counterclaim in a
lawsuit) alleging that this implementation of Tailscale or any code
incorporated within this implementation of Tailscale constitutes
direct or contributory patent infringement, or inducement of patent
infringement, then any patent rights granted to you under this License
for this implementation of Tailscale shall terminate as of the date
such litigation is filed.
================================================
FILE: README.md
================================================
# Tailscale Android Client
https://tailscale.com
Private WireGuard® networks made easy
## Overview
This repository contains the open source Tailscale Android client.
## Using
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.tailscale.ipn/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.tailscale.ipn)
## Building
[Go](https://golang.org), the [Android
SDK](https://developer.android.com/studio/releases/platform-tools),
the [Android NDK](https://developer.android.com/ndk) are required.
```sh
$ make tailscale-debug.apk
$ adb install -r tailscale-debug.apk
```
The `dockershell` target builds a container with the necessary
dependencies and runs a shell inside it.
```sh
$ make dockershell
# make tailscale-debug.apk
```
If you have Nix 2.4 or later installed, a Nix development environment can
be set up with
```sh
$ alias nix='nix --extra-experimental-features "nix-command flakes"'
$ nix develop
```
Use `make tag_release` to bump the Android version code, update the version
name, and tag the current commit.
We only guarantee to support the latest Go release and any Go beta or
release candidate builds (currently Go 1.14) in module mode. It might
work in earlier Go versions or in GOPATH mode, but we're making no
effort to keep those working.
## Google Sign-In
Google Sign-In support relies on configuring a [Google API Console
project](https://developers.google.com/identity/sign-in/android/start-integrating)
with the app identifier and [signing key
hashes](https://developers.google.com/android/guides/client-auth).
The official release uses the app identifier `com.tailscale.ipn`;
custom builds should use a different identifier.
## Running in the Android emulator
By default, the android emulator uses an older version of OpenGL ES,
which results in a black screen when opening the Tailscale app. To fix
this, with the emulator running:
- Open the three-dots menu to access emulator settings
- To to `Settings > Advanced`
- Set "OpenGL ES API level" to "Renderer maximum (up to OpenGL ES 3.1)"
- Close the emulator.
- In Android Studio's emulator view (that lists all your emulated
devices), hit the down arrow by the virtual device and select "Cold
boot now" to restart the emulator from scratch.
The Tailscale app should now render correctly.
Additionally, there seems to be a bug that prevents using the
system-level Google sign-in option (the one that pops up a
system-level UI to select your Google account). You can work around
this by selecting "Other" at the sign-in screen, and then selecting
Google from the next screen.
## Developing on a Fire Stick TV
On the Fire Stick:
* Settings > My Fire TV > Developer Options > ADB Debugging > ON
Then some useful commands:
```
adb connect 10.2.200.213:5555
adb install -r tailscale-fdroid.apk
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
adb shell pm uninstall com.tailscale.ipn
```
## Building on macOS
To build from the CLI on macOS:
1. Install Android Studio
2. In Android Studio's home screen: "More Actions" > "SDK Manager", install NDK.
3. You can now close Android Studio, unless you want it to create virtual devices
("More Actions" > "Virtual Device Manager").
4. Then, from CLI:
5. `export JAVA_HOME='/Applications/Android Studio.app/Contents/jre/Contents/Home'`
6. `export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk`
7. `make tailscale-fdroid.apk`, etc
## Bugs
Please file any issues about this code or the hosted service on
[the tailscale issue tracker](https://github.com/tailscale/tailscale/issues).
## Contributing
`under_construction.gif`
PRs welcome, but we are still working out our contribution process and
tooling.
We require [Developer Certificate of
Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
`Signed-off-by` lines in commits.
## About Us
We are apenwarr, bradfitz, crawshaw, danderson, dfcarney,
from Tailscale Inc.
You can learn more about us from [our website](https://tailscale.com).
WireGuard is a registered trademark of Jason A. Donenfeld.
================================================
FILE: android/build.gradle
================================================
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.0'
}
}
allprojects {
repositories {
google()
jcenter()
flatDir {
dirs 'libs'
}
}
}
apply plugin: 'com.android.application'
android {
ndkVersion "23.1.7779620"
compileSdkVersion 30
defaultConfig {
minSdkVersion 22
targetSdkVersion 30
versionCode 114
versionName "1.29.0-t3c892d106-g42f688f1292"
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
flavorDimensions "version"
productFlavors {
fdroid {
// The fdroid flavor contains only free dependencies and is suitable
// for the F-Droid app store.
}
play {
// The play flavor contains all features and is for the Play Store.
}
}
}
dependencies {
implementation "androidx.core:core:1.2.0"
implementation "androidx.browser:browser:1.2.0"
implementation "androidx.security:security-crypto:1.1.0-alpha03"
implementation ':ipn@aar'
testCompile "junit:junit:4.12"
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:18.0.0'
}
================================================
FILE: android/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=3239b5ed86c3838a37d983ac100573f64c1f3fd8e1eb6c89fa5f9529b5ec091d
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: android/gradle.properties
================================================
android.useAndroidX=true
================================================
FILE: android/gradlew
================================================
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
================================================
FILE: android/gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: android/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tailscale.ipn">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
<!-- Signal support for Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/tv_banner"
android:name=".App" android:allowBackup="false">
<activity android:name="IPNActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
</application>
</manifest>
================================================
FILE: android/src/main/java/com/tailscale/ipn/App.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Application;
import android.app.Activity;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.Signature;
import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.Manifest;
import android.webkit.MimeTypeMap;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.lang.StringBuilder;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import androidx.browser.customtabs.CustomTabsIntent;
import org.gioui.Gio;
public class App extends Application {
private final static String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2;
private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
private final static Handler mainHandler = new Handler(Looper.getMainLooper());
public DnsConfig dns = new DnsConfig(this);
public DnsConfig getDnsConfigObj() { return this.dns; }
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
registerNetworkCallback();
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
}
private void registerNetworkCallback() {
ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() {
private void reportConnectivityChange() {
NetworkInfo active = cMgr.getActiveNetworkInfo();
// https://developer.android.com/training/monitoring-device-state/connectivity-status-type
boolean isConnected = active != null && active.isConnectedOrConnecting();
onConnectivityChanged(isConnected);
}
@Override
public void onLost(Network network) {
super.onLost(network);
this.reportConnectivityChange();
}
@Override
public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
super.onLinkPropertiesChanged(network, linkProperties);
this.reportConnectivityChange();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_CONNECT);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_DISCONNECT);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name");
String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name");
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth;
if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth;
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
}
});
}
static void startActivityForResult(Activity act, Intent intent, int request) {
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
f.startActivityForResult(intent, request);
}
void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
static native void onVPNPrepared();
private static native void onConnectivityChanged(boolean connected);
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted();
// Returns details of the interfaces in the system, encoded as a single string for ease
// of JNI transfer over to the Go environment.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
}
StringBuilder sb = new StringBuilder("");
for (NetworkInterface nif : interfaces) {
try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
// one, so we say the interface has broadcast if it has multicast.
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
// InterfaceAddress == hostname + "/" + IP
String[] parts = ia.toString().split("/", 0);
if (parts.length > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
}
}
} catch (Exception e) {
// TODO(dgentry) should log the exception not silently suppress it.
continue;
}
sb.append("\n");
}
return sb.toString();
}
boolean isTV() {
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
}
================================================
FILE: android/src/main/java/com/tailscale/ipn/DnsConfig.java
================================================
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.DhcpInfo;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkInfo;
import android.net.wifi.WifiManager;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
// Tailscale DNS Config retrieval
//
// Tailscale's DNS support can either override the local DNS servers with a set of servers
// configured in the admin panel, or supplement the local DNS servers with additional
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
// we need to retrieve the current set of DNS servers from the platform. These will typically
// be the DNS servers received from DHCP.
//
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
//
// --------------------- Android 7 and later -----------------------------------------
//
// ## getDnsConfigFromLinkProperties
// Android provides a getAllNetworks interface in the ConnectivityManager. We walk through
// each interface to pick the most appropriate one.
// - If there is an Ethernet interface active we use that.
// - If Wi-Fi is active we use that.
// - If LTE is active we use that.
// - We never use a VPN's DNS servers. That VPN is likely us. Even if not us, Android
// only allows one VPN at a time so a different VPN's DNS servers won't be available
// once Tailscale comes up.
//
// getAllNetworks() is used as the sole mechanism for retrieving the DNS config with
// Android 7 and later.
//
// --------------------- Releases older than Android 7 -------------------------------
//
// We support Tailscale back to Android 5. Android versions 5 and 6 supply a getAllNetworks()
// implementation but it always returns an empty list.
//
// ## getDnsConfigFromLinkProperties with getActiveNetwork
// ConnectivityManager also supports a getActiveNetwork() routine, which Android 5 and 6 do
// return a value for. If Tailscale isn't up yet and we can get the Wi-Fi/LTE/etc DNS
// config using getActiveNetwork(), we use that.
//
// Once Tailscale is up, getActiveNetwork() returns tailscale0 with DNS server 100.100.100.100
// and that isn't useful. So we try two other mechanisms:
//
// ## getDnsServersFromSystemProperties
// Android versions prior to 8 let us retrieve the actual system DNS servers from properties.
// Later Android versions removed the properties and only return an empty string.
//
// We check the net.dns1 - net.dns4 DNS servers. If Tailscale is up the DNS server will be
// 100.100.100.100, which isn't useful, but if we get something different we'll use that.
//
// getDnsServersFromSystemProperties can only retrieve the IPv4 or IPv6 addresses of the
// configured DNS servers. We also want to know the DNS Search Domains configured, but
// we have no way to retrieve this using these interfaces. We return an empty list of
// search domains. Sorry.
//
// ## getDnsServersFromNetworkInfo
// ConnectivityManager supports an older API called getActiveNetworkInfo to return the
// active network interface. It doesn't handle VPNs, so the interface will always be Wi-Fi
// or Cellular even if Tailscale is up.
//
// For Wi-Fi interfaces we retrieve the DHCP response from the WifiManager. For Cellular
// interfaces we check for properties populated by most of the radio drivers.
//
// getDnsServersFromNetworkInfo does not have a way to retrieve the DNS Search Domains,
// so we return an empty list. Additionally, these interfaces are so old that they only
// support IPv4. We can't retrieve IPv6 DNS server addresses this way.
public class DnsConfig {
private Context ctx;
public DnsConfig(Context ctx) {
this.ctx = ctx;
}
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces
//
// For example:
// 8.8.8.8 8.8.4.4
// example.com
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
String s = getDnsConfigFromLinkProperties();
if (!s.trim().isEmpty()) {
return s;
}
if (android.os.Build.VERSION.SDK_INT >= 23) {
// If ConnectivityManager.getAllNetworks() works, it is the
// authoritative mechanism and we rely on it. The other methods
// which follow involve more compromises.
return "";
}
s = getDnsServersFromSystemProperties();
if (!s.trim().isEmpty()) {
return s;
}
return getDnsServersFromNetworkInfo();
}
// getDnsConfigFromLinkProperties finds the DNS servers for each Network interface
// returned by ConnectivityManager getAllNetworks().LinkProperties, and return the
// one that (heuristically) would be the primary DNS servers.
//
// on a Nexus 4 with Android 5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Nexus 7 with Android 6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Pixel 3a with Android 12.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1\nlocaldomain
// on a Pixel 3a with Android 12.0 on LTE: fd00:976a::9 fd00:976a::10
//
// One odd behavior noted on Pixel3a with Android 12:
// With Wi-Fi already connected, starting Tailscale returned DNS servers 2602:248:7b4a:ff60::1 10.1.10.1
// Turning off Wi-Fi and connecting LTE returned DNS servers fd00:976a::9 fd00:976a::10.
// Turning Wi-Fi back on return DNS servers: 10.1.10.1. The IPv6 DNS server is gone.
// This appears to be the ConnectivityManager behavior, not something we are doing.
//
// This implementation can work through Android 12 (SDK 30). In SDK 31 the
// getAllNetworks() method is deprecated and we'll need to implement a
// android.net.ConnectivityManager.NetworkCallback instead to monitor
// link changes and track which DNS server to use.
String getDnsConfigFromLinkProperties() {
ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cMgr == null) {
return "";
}
Network[] networks = cMgr.getAllNetworks();
if (networks == null) {
// Android 6 and before often returns an empty list, but we
// can try again with just the active network.
//
// Once Tailscale is connected, the active network will be Tailscale
// which will have 100.100.100.100 for its DNS server. We reject
// TYPE_VPN in getPreferabilityForNetwork, so it won't be returned.
Network active = cMgr.getActiveNetwork();
if (active == null) {
return "";
}
networks = new Network[]{active};
}
// getPreferabilityForNetwork returns an index into dnsConfigs from 0-3.
String[] dnsConfigs = new String[]{"", "", "", ""};
for (Network network : networks) {
int idx = getPreferabilityForNetwork(cMgr, network);
if ((idx < 0) || (idx > 3)) {
continue;
}
LinkProperties linkProp = cMgr.getLinkProperties(network);
NetworkCapabilities nc = cMgr.getNetworkCapabilities(network);
List<InetAddress> dnsList = linkProp.getDnsServers();
StringBuilder sb = new StringBuilder("");
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress() + " ");
}
String d = linkProp.getDomains();
if (d != null) {
sb.append("\n");
sb.append(d);
}
dnsConfigs[idx] = sb.toString();
}
// return the lowest index DNS config which exists. If an Ethernet config
// was found, return it. Otherwise if Wi-fi was found, return it. Etc.
for (String s : dnsConfigs) {
if (!s.trim().isEmpty()) {
return s;
}
}
return "";
}
// getDnsServersFromSystemProperties returns DNS servers found in system properties.
// On Android versions prior to Android 8, we can directly query the DNS
// servers the system is using. More recent Android releases return empty strings.
//
// Once Tailscale is connected these properties will return 100.100.100.100, which we
// suppress.
//
// on a Nexus 4 with Android 5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Nexus 7 with Android 6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Pixel 3a with Android 12.0 on wifi:
// on a Pixel 3a with Android 12.0 on LTE:
//
// The list of DNS search domains does not appear to be available in system properties.
String getDnsServersFromSystemProperties() {
try {
Class SystemProperties = Class.forName("android.os.SystemProperties");
Method method = SystemProperties.getMethod("get", String.class);
List<String> servers = new ArrayList<String>();
for (String name : new String[]{"net.dns1", "net.dns2", "net.dns3", "net.dns4"}) {
String value = (String) method.invoke(null, name);
if (value != null && !value.isEmpty() &&
!value.equals("100.100.100.100") &&
!servers.contains(value)) {
servers.add(value);
}
}
return String.join(" ", servers);
} catch (Exception e) {
return "";
}
}
public String intToInetString(int hostAddress) {
return String.format(java.util.Locale.ROOT, "%d.%d.%d.%d",
(0xff & hostAddress),
(0xff & (hostAddress >> 8)),
(0xff & (hostAddress >> 16)),
(0xff & (hostAddress >> 24)));
}
// getDnsServersFromNetworkInfo retrieves DNS servers using ConnectivityManager
// getActiveNetworkInfo() plus interface-specific mechanisms to retrieve the DNS servers.
// Only IPv4 DNS servers are supported by this mechanism, neither the WifiManager nor the
// interface-specific dns properties appear to populate IPv6 DNS server addresses.
//
// on a Nexus 4 with Android 5.1 on wifi: 10.1.10.1
// on a Nexus 7 with Android 6.0 on wifi: 10.1.10.1
// on a Pixel-3a with Android 12.0 on wifi: 10.1.10.1
// on a Pixel-3a with Android 12.0 on LTE:
//
// The list of DNS search domains is not available in this way.
String getDnsServersFromNetworkInfo() {
ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cMgr == null) {
return "";
}
NetworkInfo info = cMgr.getActiveNetworkInfo();
if (info == null) {
return "";
}
Class SystemProperties;
Method method;
try {
SystemProperties = Class.forName("android.os.SystemProperties");
method = SystemProperties.getMethod("get", String.class);
} catch (Exception e) {
return "";
}
List<String> servers = new ArrayList<String>();
switch(info.getType()) {
case ConnectivityManager.TYPE_WIFI:
case ConnectivityManager.TYPE_WIMAX:
for (String name : new String[]{
"net.wifi0.dns1", "net.wifi0.dns2", "net.wifi0.dns3", "net.wifi0.dns4",
"net.wlan0.dns1", "net.wlan0.dns2", "net.wlan0.dns3", "net.wlan0.dns4",
"net.eth0.dns1", "net.eth0.dns2", "net.eth0.dns3", "net.eth0.dns4",
"dhcp.wlan0.dns1", "dhcp.wlan0.dns2", "dhcp.wlan0.dns3", "dhcp.wlan0.dns4",
"dhcp.tiwlan0.dns1", "dhcp.tiwlan0.dns2", "dhcp.tiwlan0.dns3", "dhcp.tiwlan0.dns4"}) {
try {
String value = (String) method.invoke(null, name);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
} catch (Exception e) {
continue;
}
}
WifiManager wMgr = (WifiManager) ctx.getSystemService(Context.WIFI_SERVICE);
if (wMgr != null) {
DhcpInfo dhcp = wMgr.getDhcpInfo();
if (dhcp.dns1 != 0) {
String value = intToInetString(dhcp.dns1);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
}
if (dhcp.dns2 != 0) {
String value = intToInetString(dhcp.dns2);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
}
}
return String.join(" ", servers);
case ConnectivityManager.TYPE_MOBILE:
case ConnectivityManager.TYPE_MOBILE_HIPRI:
for (String name : new String[]{
"net.rmnet0.dns1", "net.rmnet0.dns2", "net.rmnet0.dns3", "net.rmnet0.dns4",
"net.rmnet1.dns1", "net.rmnet1.dns2", "net.rmnet1.dns3", "net.rmnet1.dns4",
"net.rmnet2.dns1", "net.rmnet2.dns2", "net.rmnet2.dns3", "net.rmnet2.dns4",
"net.rmnet3.dns1", "net.rmnet3.dns2", "net.rmnet3.dns3", "net.rmnet3.dns4",
"net.rmnet4.dns1", "net.rmnet4.dns2", "net.rmnet4.dns3", "net.rmnet4.dns4",
"net.rmnet5.dns1", "net.rmnet5.dns2", "net.rmnet5.dns3", "net.rmnet5.dns4",
"net.rmnet6.dns1", "net.rmnet6.dns2", "net.rmnet6.dns3", "net.rmnet6.dns4",
"net.rmnet7.dns1", "net.rmnet7.dns2", "net.rmnet7.dns3", "net.rmnet7.dns4",
"net.pdp0.dns1", "net.pdp0.dns2", "net.pdp0.dns3", "net.pdp0.dns4",
"net.pdpbr0.dns1", "net.pdpbr0.dns2", "net.pdpbr0.dns3", "net.pdpbr0.dns4"}) {
try {
String value = (String) method.invoke(null, name);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
} catch (Exception e) {
continue;
}
}
}
return "";
}
// getPreferabilityForNetwork is a utility routine which implements a priority for
// different types of network transport, used in a heuristic to pick DNS servers to use.
int getPreferabilityForNetwork(ConnectivityManager cMgr, Network network) {
NetworkCapabilities nc = cMgr.getNetworkCapabilities(network);
if (nc == null) {
return -1;
}
if (nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
// tun0 has both VPN and WIFI set, have to check VPN first and return.
return -1;
}
if (nc.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
return 0;
} else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
return 1;
} else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
return 2;
} else {
return 3;
}
}
}
================================================
FILE: android/src/main/java/com/tailscale/ipn/IPNActivity.java
================================================
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.net.Uri;
import android.content.pm.PackageManager;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView;
public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000;
private GioView view;
@Override public void onCreate(Bundle state) {
super.onCreate(state);
view = new GioView(this);
setContentView(view);
handleIntent();
}
@Override public void onNewIntent(Intent i) {
setIntent(i);
handleIntent();
}
private void handleIntent() {
Intent it = getIntent();
String act = it.getAction();
String[] texts;
Uri[] uris;
if (Intent.ACTION_SEND.equals(act)) {
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uris = extraUris.toArray(new Uri[0]);
texts = new String[uris.length];
} else {
return;
}
String mime = it.getType();
int nitems = uris.length;
String[] items = new String[nitems];
String[] mimes = new String[nitems];
int[] types = new int[nitems];
String[] names = new String[nitems];
long[] sizes = new long[nitems];
int nfiles = 0;
for (int i = 0; i < uris.length; i++) {
String text = texts[i];
Uri uri = uris[i];
if (text != null) {
types[nfiles] = 1; // FileTypeText
names[nfiles] = "file.txt";
mimes[nfiles] = mime;
items[nfiles] = text;
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
sizes[nfiles] = 0;
nfiles++;
} else if (uri != null) {
Cursor c = getContentResolver().query(uri, null, null, null, null);
if (c == null) {
// Ignore files we have no permission to access.
continue;
}
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
c.moveToFirst();
String name = c.getString(nameCol);
long size = c.getLong(sizeCol);
types[nfiles] = 2; // FileTypeURI
mimes[nfiles] = mime;
items[nfiles] = uri.toString();
names[nfiles] = name;
sizes[nfiles] = size;
nfiles++;
}
}
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
switch (reqCode) {
case WRITE_STORAGE_RESULT:
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
App.onWriteStorageGranted();
}
}
}
@Override public void onDestroy() {
view.destroy();
super.onDestroy();
}
@Override public void onStart() {
super.onStart();
view.start();
}
@Override public void onStop() {
view.stop();
super.onStop();
}
@Override public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c);
view.configurationChanged();
}
@Override public void onLowMemory() {
super.onLowMemory();
view.onLowMemory();
}
@Override public void onBackPressed() {
if (!view.backPressed())
super.onBackPressed();
}
}
================================================
FILE: android/src/main/java/com/tailscale/ipn/IPNService.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.os.Build;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.VpnService;
import android.system.OsConstants;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
close();
return START_NOT_STICKY;
}
connect();
return START_STICKY;
}
private void close() {
stopForeground(true);
disconnect();
}
@Override public void onDestroy() {
close();
super.onDestroy();
}
@Override public void onRevoke() {
close();
super.onRevoke();
}
private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
}
private void disallowApp(VpnService.Builder b, String name) {
try {
b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) {
return;
}
}
protected VpnService.Builder newBuilder() {
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
b.setMetered(false); // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
b.setUnderlyingNetworks(null); // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
this.disallowApp(b, "com.google.android.apps.messaging");
// Stadia https://github.com/tailscale/tailscale/issues/3460
this.disallowApp(b, "com.google.stadia.android");
// Android Auto https://github.com/tailscale/tailscale/issues/3828
this.disallowApp(b, "com.google.android.projection.gearhead");
return b;
}
public void notify(String title, String message) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
}
public void updateStatusNotification(String title, String message) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW);
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
}
private native void connect();
private native void disconnect();
}
================================================
FILE: android/src/main/java/com/tailscale/ipn/Peer.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
onActivityResult0(getActivity(), requestCode, resultCode);
}
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
}
================================================
FILE: android/src/main/java/com/tailscale/ipn/QuickToggleService.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.content.Context;
import android.content.ComponentName;
import android.content.Intent;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicBoolean;
public class QuickToggleService extends TileService {
// lock protects the static fields below it.
private static Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
@Override public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@Override public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
}
if (r) {
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
private static void updateTile() {
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = active && ready;
}
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
ready = rdy;
}
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
private static native void onTileClick();
}
================================================
FILE: android/src/main/res/drawable/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<group android:translateX="20"
android:translateY="20">
<path
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
</group>
</vector>
================================================
FILE: android/src/main/res/drawable/ic_tile.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<path
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
</group>
</vector>
================================================
FILE: android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
================================================
FILE: android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
================================================
FILE: android/src/main/res/values/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1F2125</color>
</resources>
================================================
FILE: android/src/main/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tailscale</string>
<string name="tile_name">Tailscale</string>
</resources>
================================================
FILE: android/src/play/java/com/tailscale/ipn/Google.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.content.Intent;
import android.content.Context;
import com.google.android.gms.auth.api.signin.GoogleSignIn;
import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
import com.google.android.gms.auth.api.signin.GoogleSignInClient;
import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
// Google implements helpers for Google services.
public final class Google {
static String getIdTokenForActivity(Activity act) {
GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(act);
return acc.getIdToken();
}
static void googleSignIn(Activity act, String serverOAuthID, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(serverOAuthID)
.requestEmail()
.build();
GoogleSignInClient client = GoogleSignIn.getClient(act, gso);
Intent signInIntent = client.getSignInIntent();
App.startActivityForResult(act, signInIntent, reqCode);
}
});
}
static void googleSignOut(Context ctx) {
GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.build();
GoogleSignInClient client = GoogleSignIn.getClient(ctx, gso);
client.signOut();
}
}
================================================
FILE: android/src/test/java/com/tailscale/ipn/DnsConfigTest.java
================================================
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import com.tailscale.ipn.DnsConfig;
public class DnsConfigTest {
DnsConfig dns;
@Before
public void setup() {
dns = new DnsConfig(null);
}
@Test
public void dnsConfig_intToInetStringTest() {
assertEquals(dns.intToInetString(0x0101a8c0), "192.168.1.1");
assertEquals(dns.intToInetString(0x04030201), "1.2.3.4");
assertEquals(dns.intToInetString(0), "0.0.0.0");
}
}
================================================
FILE: cmd/tailscale/backend.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"errors"
"fmt"
"log"
"net/http"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/tailscale/tailscale-android/jni"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/tun"
"inet.af/netaddr"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/logtail/filch"
"tailscale.com/net/dns"
"tailscale.com/net/tsdial"
"tailscale.com/smallzstd"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/wgengine/router"
)
type backend struct {
engine wgengine.Engine
backend *ipnlocal.LocalBackend
devices *multiTUN
settings settingsFunc
lastCfg *router.Config
lastDNSCfg *dns.OSConfig
logIDPublic string
// avoidEmptyDNS controls whether to use fallback nameservers
// when no nameservers are provided by Tailscale.
avoidEmptyDNS bool
jvm *jni.JVM
appCtx jni.Object
}
type settingsFunc func(*router.Config, *dns.OSConfig) error
const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go
const (
logPrefKey = "privatelogid"
loginMethodPrefKey = "loginmethod"
customLoginServerPrefKey = "customloginserver"
)
const (
loginMethodGoogle = "google"
loginMethodWeb = "web"
)
// googleDnsServers are used on ChromeOS, where an empty VpnBuilder DNS setting results
// in erasing the platform DNS servers. The developer docs say this is not supposed to happen,
// but nonetheless it does.
var googleDnsServers = []netaddr.IP{netaddr.MustParseIP("8.8.8.8"), netaddr.MustParseIP("8.8.4.4"),
netaddr.MustParseIP("2001:4860:4860::8888"), netaddr.MustParseIP("2001:4860:4860::8844"),
}
// errVPNNotPrepared is used when VPNService.Builder.establish returns
// null, either because the VPNService is not yet prepared or because
// VPN status was revoked.
var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked")
func newBackend(dataDir string, jvm *jni.JVM, appCtx jni.Object, store *stateStore,
settings settingsFunc) (*backend, error) {
logf := logger.RusagePrefixLog(log.Printf)
b := &backend{
jvm: jvm,
devices: newTUNDevices(),
settings: settings,
appCtx: appCtx,
}
var logID logtail.PrivateID
logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000"))
storedLogID, err := store.read(logPrefKey)
// In all failure cases we ignore any errors and continue with the dead value above.
if err != nil || storedLogID == nil {
// Read failed or there was no previous log id.
newLogID, err := logtail.NewPrivateID()
if err == nil {
logID = newLogID
enc, err := newLogID.MarshalText()
if err == nil {
store.write(logPrefKey, enc)
}
}
} else {
logID.UnmarshalText([]byte(storedLogID))
}
b.SetupLogs(dataDir, logID)
dialer := new(tsdial.Dialer)
cb := &router.CallbackRouter{
SetBoth: b.setCfg,
SplitDNS: false,
GetBaseConfigFunc: b.getDNSBaseConfig,
}
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: b.devices,
Router: cb,
DNS: cb,
Dialer: dialer,
})
if err != nil {
return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err)
}
b.logIDPublic = logID.Public().String()
tunDev, magicConn, dnsMgr, ok := engine.(wgengine.InternalsGetter).GetInternals()
if !ok {
return nil, fmt.Errorf("%T is not a wgengine.InternalsGetter", engine)
}
ns, err := netstack.Create(logf, tunDev, engine, magicConn, dialer, dnsMgr)
if err != nil {
return nil, fmt.Errorf("netstack.Create: %w", err)
}
ns.ProcessLocalIPs = false // let Android kernel handle it; VpnBuilder sets this up
ns.ProcessSubnets = true // for Android-being-an-exit-node support
lb, err := ipnlocal.NewLocalBackend(logf, b.logIDPublic, store, dialer, engine, 0)
if err != nil {
engine.Close()
return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err)
}
ns.SetLocalBackend(lb)
if err := ns.Start(); err != nil {
return nil, fmt.Errorf("startNetstack: %w", err)
}
b.engine = engine
b.backend = lb
return b, nil
}
func (b *backend) Start(notify func(n ipn.Notify)) error {
b.backend.SetNotifyCallback(notify)
return b.backend.Start(ipn.Options{
StateKey: "ipn-android",
})
}
func (b *backend) LinkChange() {
if b.engine != nil {
b.engine.LinkChange(false)
}
}
func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error {
return b.settings(rcfg, dcfg)
}
func (b *backend) updateTUN(service jni.Object, rcfg *router.Config, dcfg *dns.OSConfig) error {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
return nil
}
// Close previous tunnel(s).
// This is necessary for ChromeOS, native Android devices
// seem to handle seamless handover between tunnels correctly.
//
// TODO(eliasnaur): If seamless handover becomes a desirable feature, skip
// the closing on ChromeOS.
b.CloseTUNs()
if len(rcfg.LocalAddrs) == 0 {
return nil
}
err := jni.Do(b.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, service)
// Construct a VPNService.Builder. IPNService.newBuilder calls
// setConfigureIntent, and allowFamily for both IPv4 and IPv6.
m := jni.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;")
builder, err := jni.CallObjectMethod(env, service, m)
if err != nil {
return fmt.Errorf("IPNService.newBuilder: %v", err)
}
bcls := jni.GetObjectClass(env, builder)
// builder.setMtu.
setMtu := jni.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;")
const mtu = defaultMTU
if _, err := jni.CallObjectMethod(env, builder, setMtu, jni.Value(mtu)); err != nil {
return fmt.Errorf("VpnService.Builder.setMtu: %v", err)
}
// builder.addDnsServer
addDnsServer := jni.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
// builder.addSearchDomain.
addSearchDomain := jni.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
if dcfg != nil {
nameservers := dcfg.Nameservers
if b.avoidEmptyDNS && len(nameservers) == 0 {
nameservers = googleDnsServers
}
for _, dns := range nameservers {
_, err = jni.CallObjectMethod(env,
builder,
addDnsServer,
jni.Value(jni.JavaString(env, dns.String())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addDnsServer(%v): %v", dns, err)
}
}
for _, dom := range dcfg.SearchDomains {
_, err = jni.CallObjectMethod(env,
builder,
addSearchDomain,
jni.Value(jni.JavaString(env, dom.WithoutTrailingDot())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addSearchDomain(%v): %v", dom, err)
}
}
}
// builder.addRoute.
addRoute := jni.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, route := range rcfg.Routes {
// Normalize route address; Builder.addRoute does not accept non-zero masked bits.
route = route.Masked()
_, err = jni.CallObjectMethod(env,
builder,
addRoute,
jni.Value(jni.JavaString(env, route.IP().String())),
jni.Value(route.Bits()),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addRoute(%v): %v", route, err)
}
}
// builder.addAddress.
addAddress := jni.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, addr := range rcfg.LocalAddrs {
_, err = jni.CallObjectMethod(env,
builder,
addAddress,
jni.Value(jni.JavaString(env, addr.IP().String())),
jni.Value(addr.Bits()),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addAddress(%v): %v", addr, err)
}
}
// builder.establish.
establish := jni.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;")
parcelFD, err := jni.CallObjectMethod(env, builder, establish)
if err != nil {
return fmt.Errorf("VpnService.Builder.establish: %v", err)
}
if parcelFD == 0 {
return errVPNNotPrepared
}
// detachFd.
parcelCls := jni.GetObjectClass(env, parcelFD)
detachFd := jni.GetMethodID(env, parcelCls, "detachFd", "()I")
tunFD, err := jni.CallIntMethod(env, parcelFD, detachFd)
if err != nil {
return fmt.Errorf("detachFd: %v", err)
}
// Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil {
unix.Close(int(tunFD))
return err
}
b.devices.add(tunDev)
return nil
})
if err != nil {
b.lastCfg = nil
b.CloseTUNs()
return err
}
b.lastCfg = rcfg
b.lastDNSCfg = dcfg
return nil
}
// CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() {
b.lastCfg = nil
b.devices.Shutdown()
}
// SetupLogs sets up remote logging.
func (b *backend) SetupLogs(logDir string, logID logtail.PrivateID) {
logcfg := logtail.Config{
Collection: "tailnode.log.tailscale.io",
PrivateID: logID,
Stderr: log.Writer(),
NewZstdEncoder: func() logtail.Encoder {
w, err := smallzstd.NewEncoder(nil)
if err != nil {
panic(err)
}
return w
},
HTTPC: &http.Client{Transport: logpolicy.NewLogtailTransport(logtail.DefaultHost)},
}
drainCh := make(chan struct{})
logcfg.DrainLogs = drainCh
go func() {
// Upload logs infrequently. Interval chosen arbitrarily.
// The objective is to reduce phone power use.
t := time.NewTicker(2 * time.Minute)
for range t.C {
select {
case drainCh <- struct{}{}:
default:
}
}
}()
filchOpts := filch.Options{
ReplaceStderr: true,
}
var filchErr error
if logDir != "" {
logPath := filepath.Join(logDir, "ipn.log.")
logcfg.Buffer, filchErr = filch.New(logPath, filchOpts)
}
logf := logger.RusagePrefixLog(log.Printf)
tlog := logtail.NewLogger(logcfg, logf)
log.SetFlags(0)
log.SetOutput(tlog)
log.Printf("goSetupLogs: success")
if logDir == "" {
log.Printf("SetupLogs: no logDir, storing logs in memory")
}
if filchErr != nil {
log.Printf("SetupLogs: filch setup failed: %v", filchErr)
}
}
// We log the result of each of the DNS configuration discovery mechanisms, as we're
// expecting a long tail of obscure Android devices with interesting behavior.
func (b *backend) logDNSConfigMechanisms() {
err := jni.Do(b.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, b.appCtx)
m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;")
dns, err := jni.CallObjectMethod(env, b.appCtx, m)
if err != nil {
return fmt.Errorf("getDnsConfigObj JNI: %v", err)
}
dnsCls := jni.GetObjectClass(env, dns)
for _, impl := range []string{"getDnsConfigFromLinkProperties",
"getDnsServersFromSystemProperties",
"getDnsServersFromNetworkInfo"} {
m = jni.GetMethodID(env, dnsCls, impl, "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, dns, m)
baseConfig := jni.GoString(env, jni.String(n))
if err != nil {
log.Printf("%s JNI: %v", impl, err)
} else {
oneLine := strings.Replace(baseConfig, "\n", ";", -1)
log.Printf("%s: %s", impl, oneLine)
}
}
return nil
})
if err != nil {
log.Printf("logDNSConfigMechanisms: %v", err)
}
}
func (b *backend) getPlatformDNSConfig() string {
var baseConfig string
err := jni.Do(b.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, b.appCtx)
m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;")
dns, err := jni.CallObjectMethod(env, b.appCtx, m)
if err != nil {
return fmt.Errorf("getDnsConfigObj: %v", err)
}
dnsCls := jni.GetObjectClass(env, dns)
m = jni.GetMethodID(env, dnsCls, "getDnsConfigAsString", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, dns, m)
baseConfig = jni.GoString(env, jni.String(n))
return err
})
if err != nil {
log.Printf("getPlatformDNSConfig JNI: %v", err)
return ""
}
return baseConfig
}
func (b *backend) getDNSBaseConfig() (dns.OSConfig, error) {
b.logDNSConfigMechanisms()
baseConfig := b.getPlatformDNSConfig()
lines := strings.Split(baseConfig, "\n")
if len(lines) == 0 {
return dns.OSConfig{}, nil
}
config := dns.OSConfig{}
addrs := strings.Trim(lines[0], " \n")
for _, addr := range strings.Split(addrs, " ") {
ip, err := netaddr.ParseIP(addr)
if err == nil {
config.Nameservers = append(config.Nameservers, ip)
}
}
if len(lines) > 1 {
for _, s := range strings.Split(strings.Trim(lines[1], " \n"), " ") {
domain, err := dnsname.ToFQDN(s)
if err != nil {
log.Printf("getDNSBaseConfig: unable to parse %q: %v", s, err)
continue
}
config.SearchDomains = append(config.SearchDomains, domain)
}
}
return config, nil
}
================================================
FILE: cmd/tailscale/callbacks.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
// JNI implementations of Java native callback methods.
import (
"unsafe"
"github.com/tailscale/tailscale-android/jni"
)
// #include <jni.h>
import "C"
var (
// onVPNPrepared is notified when VpnService.prepare succeeds.
onVPNPrepared = make(chan struct{}, 1)
// onVPNClosed is notified when VpnService.prepare fails, or when
// the a running VPN connection is closed.
onVPNClosed = make(chan struct{}, 1)
// onVPNRevoked is notified whenever the VPN service is revoked.
onVPNRevoked = make(chan struct{}, 1)
// onConnect receives global IPNService references when
// a VPN connection is requested.
onConnect = make(chan jni.Object)
// onDisconnect receives global IPNService references when
// disconnecting.
onDisconnect = make(chan jni.Object)
// onConnectivityChange is notified every time the network
// conditions change.
onConnectivityChange = make(chan bool, 1)
// onGoogleToken receives google ID tokens.
onGoogleToken = make(chan string)
// onFileShare receives file sharing intents.
onFileShare = make(chan []File, 1)
// onWriteStorageGranted is notified when we are granted WRITE_STORAGE_PERMISSION.
onWriteStorageGranted = make(chan struct{}, 1)
)
const (
// Request codes for Android callbacks.
// requestSignin is for Google Sign-In.
requestSignin C.jint = 1000 + iota
// requestPrepareVPN is for when Android's VpnService.prepare
// completes.
requestPrepareVPN
)
// resultOK is Android's Activity.RESULT_OK.
const resultOK = -1
//export Java_com_tailscale_ipn_App_onVPNPrepared
func Java_com_tailscale_ipn_App_onVPNPrepared(env *C.JNIEnv, class C.jclass) {
notifyVPNPrepared()
}
//export Java_com_tailscale_ipn_App_onWriteStorageGranted
func Java_com_tailscale_ipn_App_onWriteStorageGranted(env *C.JNIEnv, class C.jclass) {
select {
case onWriteStorageGranted <- struct{}{}:
default:
}
}
func notifyVPNPrepared() {
select {
case onVPNPrepared <- struct{}{}:
default:
}
}
func notifyVPNRevoked() {
select {
case onVPNRevoked <- struct{}{}:
default:
}
}
func notifyVPNClosed() {
select {
case onVPNClosed <- struct{}{}:
default:
}
}
//export Java_com_tailscale_ipn_IPNService_connect
func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) {
jenv := (*jni.Env)(unsafe.Pointer(env))
onConnect <- jni.NewGlobalRef(jenv, jni.Object(this))
}
//export Java_com_tailscale_ipn_IPNService_disconnect
func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) {
jenv := (*jni.Env)(unsafe.Pointer(env))
onDisconnect <- jni.NewGlobalRef(jenv, jni.Object(this))
}
//export Java_com_tailscale_ipn_App_onConnectivityChanged
func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls C.jclass, connected C.jboolean) {
select {
case <-onConnectivityChange:
default:
}
onConnectivityChange <- connected == C.JNI_TRUE
}
//export Java_com_tailscale_ipn_QuickToggleService_onTileClick
func Java_com_tailscale_ipn_QuickToggleService_onTileClick(env *C.JNIEnv, cls C.jclass) {
requestBackend(ToggleEvent{})
}
//export Java_com_tailscale_ipn_Peer_onActivityResult0
func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C.jclass, act C.jobject, reqCode, resCode C.jint) {
switch reqCode {
case requestSignin:
if resCode != resultOK {
onGoogleToken <- ""
break
}
jenv := (*jni.Env)(unsafe.Pointer(env))
m := jni.GetStaticMethodID(jenv, googleClass,
"getIdTokenForActivity", "(Landroid/app/Activity;)Ljava/lang/String;")
idToken, err := jni.CallStaticObjectMethod(jenv, googleClass, m, jni.Value(act))
if err != nil {
fatalErr(err)
break
}
tok := jni.GoString(jenv, jni.String(idToken))
onGoogleToken <- tok
case requestPrepareVPN:
if resCode == resultOK {
notifyVPNPrepared()
} else {
notifyVPNClosed()
notifyVPNRevoked()
}
}
}
//export Java_com_tailscale_ipn_App_onShareIntent
func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclass, nfiles C.jint, jtypes C.jintArray, jmimes C.jobjectArray, jitems C.jobjectArray, jnames C.jobjectArray, jsizes C.jlongArray) {
const (
typeNone = 0
typeInline = 1
typeURI = 2
)
jenv := (*jni.Env)(unsafe.Pointer(env))
types := jni.GetIntArrayElements(jenv, jni.IntArray(jtypes))
mimes := jni.GetStringArrayElements(jenv, jni.ObjectArray(jmimes))
items := jni.GetStringArrayElements(jenv, jni.ObjectArray(jitems))
names := jni.GetStringArrayElements(jenv, jni.ObjectArray(jnames))
sizes := jni.GetLongArrayElements(jenv, jni.LongArray(jsizes))
var files []File
for i := 0; i < int(nfiles); i++ {
f := File{
Type: FileType(types[i]),
MIMEType: mimes[i],
Name: names[i],
}
if f.Name == "" {
f.Name = "file.bin"
}
switch f.Type {
case FileTypeText:
f.Text = items[i]
f.Size = int64(len(f.Text))
case FileTypeURI:
f.URI = items[i]
f.Size = sizes[i]
default:
panic("unknown file type")
}
files = append(files, f)
}
select {
case <-onFileShare:
default:
}
onFileShare <- files
}
================================================
FILE: cmd/tailscale/main.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"crypto/rand"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"unsafe"
"gioui.org/app"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"inet.af/netaddr"
"github.com/tailscale/tailscale-android/jni"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/net/dns"
"tailscale.com/net/interfaces"
"tailscale.com/net/netns"
"tailscale.com/paths"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/wgengine/router"
)
type App struct {
jvm *jni.JVM
// appCtx is a global reference to the com.tailscale.ipn.App instance.
appCtx jni.Object
store *stateStore
logIDPublicAtomic atomic.Value // of string
// netStates receives the most recent network state.
netStates chan BackendState
// prefs receives new preferences from the backend.
prefs chan *ipn.Prefs
// browseURLs receives URLs when the backend wants to browse.
browseURLs chan string
// targetsLoaded receives lists of file targets.
targetsLoaded chan FileTargets
// invalidates receives whenever the window should be refreshed.
invalidates chan struct{}
}
var (
// googleClass is a global reference to the com.tailscale.ipn.Google class.
googleClass jni.Class
)
type FileTargets struct {
Targets []*apitype.FileTarget
Err error
}
type File struct {
Type FileType
Name string
Size int64
MIMEType string
// URI of the file, valid if Type is FileTypeURI.
URI string
// Text is the content of the file, if Type is FileTypeText.
Text string
}
// FileSendInfo describes the state of an ongoing file send operation.
type FileSendInfo struct {
State FileSendState
// Progress tracks the progress of the transfer from 0.0 to 1.0. Valid
// only when State is FileSendStarted.
Progress float64
}
type clientState struct {
browseURL string
backend BackendState
// query is the search query, in lowercase.
query string
Peers []UIPeer
}
type FileType uint8
// FileType constants are known to IPNActivity.java.
const (
FileTypeText FileType = 1
FileTypeURI FileType = 2
)
type ExitStatus uint8
const (
// No exit node selected.
ExitNone ExitStatus = iota
// Exit node selected and exists, but is offline or missing.
ExitOffline
// Exit node selected and online.
ExitOnline
)
type FileSendState uint8
const (
FileSendNotStarted FileSendState = iota
FileSendConnecting
FileSendTransferring
FileSendComplete
FileSendFailed
)
type Peer struct {
Label string
Online bool
ID tailcfg.StableNodeID
}
type BackendState struct {
Prefs *ipn.Prefs
State ipn.State
NetworkMap *netmap.NetworkMap
LostInternet bool
// Exits are the peers that can act as exit node.
Exits []Peer
// ExitState describes the state of our exit node.
ExitStatus ExitStatus
// Exit is our current exit node, if any.
Exit Peer
}
// UIEvent is an event flowing from the UI to the backend.
type UIEvent interface{}
type RouteAllEvent struct {
ID tailcfg.StableNodeID
}
type ConnectEvent struct {
Enable bool
}
type CopyEvent struct {
Text string
}
type SearchEvent struct {
Query string
}
type OAuth2Event struct {
Token *tailcfg.Oauth2Token
}
type FileSendEvent struct {
Target *apitype.FileTarget
Context context.Context
Updates func(FileSendInfo)
}
type SetLoginServerEvent struct {
URL string
}
// UIEvent types.
type (
ToggleEvent struct{}
ReauthEvent struct{}
BugEvent struct{}
WebAuthEvent struct{}
GoogleAuthEvent struct{}
LogoutEvent struct{}
BeExitNodeEvent bool
ExitAllowLANEvent bool
)
// serverOAuthID is the OAuth ID of the tailscale-android server, used
// by GoogleSignInOptions.Builder.requestIdToken.
const serverOAuthID = "744055068597-hv4opg0h7vskq1hv37nq3u26t8c15qk0.apps.googleusercontent.com"
// releaseCertFingerprint is the SHA-1 fingerprint of the Google Play Store signing key.
// It is used to check whether the app is signed for release.
const releaseCertFingerprint = "86:9D:11:8B:63:1E:F8:35:C6:D9:C2:66:53:BC:28:22:2F:B8:C1:AE"
// backendEvents receives events from the UI (Activity, Tile etc.) to the backend.
var backendEvents = make(chan UIEvent)
func main() {
a := &App{
jvm: (*jni.JVM)(unsafe.Pointer(app.JavaVM())),
appCtx: jni.Object(app.AppContext()),
netStates: make(chan BackendState, 1),
browseURLs: make(chan string, 1),
prefs: make(chan *ipn.Prefs, 1),
targetsLoaded: make(chan FileTargets, 1),
invalidates: make(chan struct{}, 1),
}
err := jni.Do(a.jvm, func(env *jni.Env) error {
loader := jni.ClassLoaderFor(env, a.appCtx)
cl, err := jni.LoadClass(env, loader, "com.tailscale.ipn.Google")
if err != nil {
// Ignore load errors; the Google class is not included in F-Droid builds.
return nil
}
googleClass = jni.Class(jni.NewGlobalRef(env, jni.Object(cl)))
return nil
})
if err != nil {
fatalErr(err)
}
a.store = newStateStore(a.jvm, a.appCtx)
interfaces.RegisterInterfaceGetter(a.getInterfaces)
go func() {
if err := a.runBackend(); err != nil {
fatalErr(err)
}
}()
go func() {
if err := a.runUI(); err != nil {
fatalErr(err)
}
}()
app.Main()
}
func (a *App) runBackend() error {
appDir, err := app.DataDir()
if err != nil {
fatalErr(err)
}
paths.AppSharedDir.Store(appDir)
hostinfo.SetOSVersion(a.osVersion())
if !googleSignInEnabled() {
hostinfo.SetPackage("nogoogle")
}
deviceModel := a.modelName()
if a.isChromeOS() {
deviceModel = "ChromeOS: " + deviceModel
}
hostinfo.SetDeviceModel(deviceModel)
type configPair struct {
rcfg *router.Config
dcfg *dns.OSConfig
}
configs := make(chan configPair)
configErrs := make(chan error)
b, err := newBackend(appDir, a.jvm, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
if rcfg == nil {
return nil
}
configs <- configPair{rcfg, dcfg}
return <-configErrs
})
if err != nil {
return err
}
a.logIDPublicAtomic.Store(b.logIDPublic)
defer b.CloseTUNs()
// Contrary to the documentation for VpnService.Builder.addDnsServer,
// ChromeOS doesn't fall back to the underlying network nameservers if
// we don't provide any.
b.avoidEmptyDNS = a.isChromeOS()
var timer *time.Timer
var alarmChan <-chan time.Time
alarm := func(t *time.Timer) {
if timer != nil {
timer.Stop()
}
timer = t
if timer != nil {
alarmChan = timer.C
}
}
notifications := make(chan ipn.Notify, 1)
startErr := make(chan error)
// Start from a goroutine to avoid deadlock when Start
// calls the callback.
go func() {
startErr <- b.Start(func(n ipn.Notify) {
notifications <- n
})
}()
var (
cfg configPair
state BackendState
service jni.Object // of IPNService
signingIn bool
)
var (
waitingFilesDone = make(chan struct{})
waitingFiles bool
processingFiles bool
)
processFiles := func() {
if !waitingFiles || processingFiles {
return
}
processingFiles = true
waitingFiles = false
go func() {
if err := a.processWaitingFiles(b.backend); err != nil {
log.Printf("processWaitingFiles: %v", err)
}
waitingFilesDone <- struct{}{}
}()
}
for {
select {
case err := <-startErr:
if err != nil {
return err
}
case <-waitingFilesDone:
processingFiles = false
processFiles()
case s := <-configs:
cfg = s
if b == nil || service == 0 || cfg.rcfg == nil {
configErrs <- nil
break
}
configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg)
case n := <-notifications:
exitWasOnline := state.ExitStatus == ExitOnline
if p := n.Prefs; p != nil {
first := state.Prefs == nil
state.Prefs = p.Clone()
state.updateExitNodes()
if first {
state.Prefs.Hostname = a.hostname()
go b.backend.SetPrefs(state.Prefs)
}
a.setPrefs(state.Prefs)
}
if s := n.State; s != nil {
oldState := state.State
state.State = *s
if service != 0 {
a.updateNotification(service, state.State)
}
if service != 0 {
if cfg.rcfg != nil && state.State >= ipn.Starting {
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
log.Printf("VPN update failed: %v", err)
notifyVPNClosed()
}
} else {
b.CloseTUNs()
}
}
// Stop VPN if we logged out.
if oldState > ipn.Stopped && state.State <= ipn.Stopped {
if err := a.callVoidMethod(a.appCtx, "stopVPN", "()V"); err != nil {
fatalErr(err)
}
}
a.notify(state)
}
if u := n.BrowseToURL; u != nil {
signingIn = false
a.setURL(*u)
}
if m := n.NetMap; m != nil {
state.NetworkMap = m
state.updateExitNodes()
a.notify(state)
if service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
}
}
// Notify if a previously online exit is not longer online (or missing).
if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline {
a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.")
}
targets, err := b.backend.FileTargets()
if err != nil {
// Construct a user-visible error message.
if b.backend.State() != ipn.Running {
err = fmt.Errorf("Not connected to tailscale")
} else {
err = fmt.Errorf("Failed to load device list")
}
}
a.targetsLoaded <- FileTargets{targets, err}
waitingFiles = n.FilesWaiting != nil
processFiles()
case <-onWriteStorageGranted:
processFiles()
case <-alarmChan:
if m := state.NetworkMap; m != nil && service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
}
case e := <-backendEvents:
switch e := e.(type) {
case OAuth2Event:
go b.backend.Login(e.Token)
case ToggleEvent:
state.Prefs.WantRunning = !state.Prefs.WantRunning
go b.backend.SetPrefs(state.Prefs)
case BeExitNodeEvent:
state.Prefs.SetAdvertiseExitNode(bool(e))
go b.backend.SetPrefs(state.Prefs)
case ExitAllowLANEvent:
state.Prefs.ExitNodeAllowLANAccess = bool(e)
go b.backend.SetPrefs(state.Prefs)
case WebAuthEvent:
if !signingIn {
go b.backend.StartLoginInteractive()
signingIn = true
}
case SetLoginServerEvent:
state.Prefs.ControlURL = e.URL
b.backend.SetPrefs(state.Prefs)
// A hack to get around ipnlocal's inability to update
// ControlURL after Start()... Can we re-init instead?
os.Exit(0)
case LogoutEvent:
go b.backend.Logout()
case ConnectEvent:
state.Prefs.WantRunning = e.Enable
go b.backend.SetPrefs(state.Prefs)
case RouteAllEvent:
state.Prefs.ExitNodeID = e.ID
go b.backend.SetPrefs(state.Prefs)
state.updateExitNodes()
a.notify(state)
}
case s := <-onConnect:
jni.Do(a.jvm, func(env *jni.Env) error {
if jni.IsSameObject(env, s, service) {
// We already have a reference.
jni.DeleteGlobalRef(env, s)
return nil
}
if service != 0 {
jni.DeleteGlobalRef(env, service)
}
netns.SetAndroidProtectFunc(func(fd int) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
// Call https://developer.android.com/reference/android/net/VpnService#protect(int)
// to mark fd as a socket that should bypass the VPN and use the underlying network.
cls := jni.GetObjectClass(env, s)
m := jni.GetMethodID(env, cls, "protect", "(I)Z")
ok, err := jni.CallBooleanMethod(env, s, m, jni.Value(fd))
// TODO(bradfitz): return an error back up to netns if this fails, once
// we've had some experience with this and analyzed the logs over a wide
// range of Android phones. For now we're being paranoid and conservative
// and do the JNI call to protect best effort, only logging if it fails.
// The risk of returning an error is that it breaks users on some Android
// versions even when they're not using exit nodes. I'd rather the
// relatively few number of exit node users file bug reports if Tailscale
// doesn't work and then we can look for this log print.
if err != nil || !ok {
log.Printf("[unexpected] VpnService.protect(%d) = %v, %v", fd, ok, err)
}
return nil // even on error. see big TODO above.
})
})
service = s
return nil
})
a.updateNotification(service, state.State)
if m := state.NetworkMap; m != nil {
alarm(a.notifyExpiry(service, m.Expiry))
}
if cfg.rcfg != nil && state.State >= ipn.Starting {
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
log.Printf("VPN update failed: %v", err)
notifyVPNClosed()
}
}
case connected := <-onConnectivityChange:
if state.LostInternet != !connected {
log.Printf("LostInternet state change: %v -> %v", state.LostInternet, !connected)
}
state.LostInternet = !connected
if b != nil {
go b.LinkChange()
}
a.notify(state)
case s := <-onDisconnect:
b.CloseTUNs()
jni.Do(a.jvm, func(env *jni.Env) error {
defer jni.DeleteGlobalRef(env, s)
if jni.IsSameObject(env, service, s) {
netns.SetAndroidProtectFunc(nil)
jni.DeleteGlobalRef(env, service)
service = 0
}
return nil
})
if state.State >= ipn.Starting {
notifyVPNClosed()
}
}
}
}
func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error {
files, err := b.WaitingFiles()
if err != nil {
return err
}
var aerr error
for _, f := range files {
if err := a.downloadFile(b, f); err != nil && aerr == nil {
aerr = err
}
}
return aerr
}
func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.WaitingFile) (cerr error) {
in, _, err := b.OpenFile(f.Name)
if err != nil {
return err
}
defer in.Close()
ext := filepath.Ext(f.Name)
mimeType := mime.TypeByExtension(ext)
var mediaURI string
err = jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
insertMedia := jni.GetMethodID(env, cls, "insertMedia", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")
jname := jni.JavaString(env, f.Name)
jmime := jni.JavaString(env, mimeType)
uri, err := jni.CallObjectMethod(env, a.appCtx, insertMedia, jni.Value(jname), jni.Value(jmime))
if err != nil {
return err
}
mediaURI = jni.GoString(env, jni.String(uri))
return nil
})
if err != nil {
return fmt.Errorf("insertMedia: %w", err)
}
deleteURI := func(uri string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "deleteUri", "(Ljava/lang/String;)V")
juri := jni.JavaString(env, uri)
return jni.CallVoidMethod(env, a.appCtx, m, jni.Value(juri))
})
}
out, err := a.openURI(mediaURI, "w")
if err != nil {
deleteURI(mediaURI)
return fmt.Errorf("openUri: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
deleteURI(mediaURI)
return fmt.Errorf("copy: %w", err)
}
if err := out.Close(); err != nil {
deleteURI(mediaURI)
return fmt.Errorf("close: %w", err)
}
if err := a.notifyFile(mediaURI, f.Name); err != nil {
fatalErr(err)
}
return b.DeleteFile(f.Name)
}
// openURI calls a.appCtx.getContentResolver().openFileDescriptor on uri and
// mode and returns the detached file descriptor.
func (a *App) openURI(uri, mode string) (*os.File, error) {
var f *os.File
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
openURI := jni.GetMethodID(env, cls, "openUri", "(Ljava/lang/String;Ljava/lang/String;)I")
juri := jni.JavaString(env, uri)
jmode := jni.JavaString(env, mode)
fd, err := jni.CallIntMethod(env, a.appCtx, openURI, jni.Value(juri), jni.Value(jmode))
if err != nil {
return err
}
f = os.NewFile(uintptr(fd), "media-store")
return nil
})
return f, err
}
func (a *App) isChromeOS() bool {
var chromeOS bool
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "isChromeOS", "()Z")
b, err := jni.CallBooleanMethod(env, a.appCtx, m)
chromeOS = b
return err
})
if err != nil {
panic(err)
}
return chromeOS
}
func (s *BackendState) updateExitNodes() {
s.ExitStatus = ExitNone
var exitID tailcfg.StableNodeID
if p := s.Prefs; p != nil {
exitID = p.ExitNodeID
if exitID != "" {
s.ExitStatus = ExitOffline
}
}
hasMyExit := exitID == ""
s.Exits = nil
var peers []*tailcfg.Node
if s.NetworkMap != nil {
peers = s.NetworkMap.Peers
}
for _, p := range peers {
canRoute := false
for _, r := range p.AllowedIPs {
if r == netaddr.MustParseIPPrefix("0.0.0.0/0") || r == netaddr.MustParseIPPrefix("::/0") {
canRoute = true
break
}
}
myExit := p.StableID == exitID
hasMyExit = hasMyExit || myExit
exit := Peer{
Label: p.DisplayName(true),
Online: canRoute,
ID: p.StableID,
}
if myExit {
s.Exit = exit
if canRoute {
s.ExitStatus = ExitOnline
}
}
if canRoute || myExit {
s.Exits = append(s.Exits, exit)
}
}
sort.Slice(s.Exits, func(i, j int) bool {
return s.Exits[i].Label < s.Exits[j].Label
})
if !hasMyExit {
// Insert node missing from netmap.
s.Exit = Peer{Label: "Unknown device", ID: exitID}
s.Exits = append([]Peer{s.Exit}, s.Exits...)
}
}
// hostname builds a hostname from android.os.Build fields, in place of a
// useless os.Hostname().
func (a *App) hostname() string {
var hostname string
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
getHostname := jni.GetMethodID(env, cls, "getHostname", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, a.appCtx, getHostname)
hostname = jni.GoString(env, jni.String(n))
return err
})
if err != nil {
panic(err)
}
return hostname
}
// osVersion returns android.os.Build.VERSION.RELEASE. " [nogoogle]" is appended
// if Google Play services are not compiled in.
func (a *App) osVersion() string {
var version string
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "getOSVersion", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, a.appCtx, m)
version = jni.GoString(env, jni.String(n))
return err
})
if err != nil {
panic(err)
}
return version
}
// modelName return the MANUFACTURER + MODEL from
// android.os.Build.
func (a *App) modelName() string {
var model string
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "getModelName", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, a.appCtx, m)
model = jni.GoString(env, jni.String(n))
return err
})
if err != nil {
panic(err)
}
return model
}
func googleSignInEnabled() bool {
return googleClass != 0
}
// updateNotification updates the foreground persistent status notification.
func (a *App) updateNotification(service jni.Object, state ipn.State) error {
var msg, title string
switch state {
case ipn.Starting:
title, msg = "Connecting...", ""
case ipn.Running:
title, msg = "Connected", ""
default:
return nil
}
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, service)
update := jni.GetMethodID(env, cls, "updateStatusNotification", "(Ljava/lang/String;Ljava/lang/String;)V")
jtitle := jni.JavaString(env, title)
jmessage := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, service, update, jni.Value(jtitle), jni.Value(jmessage))
})
}
// notifyExpiry notifies the user of imminent session expiry and
// returns a new timer that triggers when the user should be notified
// again.
func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer {
if expiry.IsZero() {
return nil
}
d := time.Until(expiry)
var title string
const msg = "Reauthenticate to maintain the connection to your network."
var t *time.Timer
const (
aday = 24 * time.Hour
soon = 5 * time.Minute
)
switch {
case d <= 0:
title = "Your authentication has expired!"
case d <= soon:
title = "Your authentication expires soon!"
t = time.NewTimer(d)
case d <= aday:
title = "Your authentication expires in a day."
t = time.NewTimer(d - soon)
default:
return time.NewTimer(d - aday)
}
if err := a.pushNotify(service, title, msg); err != nil {
fatalErr(err)
}
return t
}
func (a *App) notifyFile(uri, msg string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
notify := jni.GetMethodID(env, cls, "notifyFile", "(Ljava/lang/String;Ljava/lang/String;)V")
juri := jni.JavaString(env, uri)
jmsg := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, a.appCtx, notify, jni.Value(juri), jni.Value(jmsg))
})
}
func (a *App) pushNotify(service jni.Object, title, msg string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, service)
notify := jni.GetMethodID(env, cls, "notify", "(Ljava/lang/String;Ljava/lang/String;)V")
jtitle := jni.JavaString(env, title)
jmessage := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, service, notify, jni.Value(jtitle), jni.Value(jmessage))
})
}
func (a *App) notify(state BackendState) {
select {
case <-a.netStates:
default:
}
a.netStates <- state
ready := jni.Bool(state.State >= ipn.Stopped)
if err := a.callVoidMethod(a.appCtx, "setTileReady", "(Z)V", jni.Value(ready)); err != nil {
fatalErr(err)
}
}
func (a *App) setPrefs(prefs *ipn.Prefs) {
wantRunning := jni.Bool(prefs.WantRunning)
if err := a.callVoidMethod(a.appCtx, "setTileStatus", "(Z)V", jni.Value(wantRunning)); err != nil {
fatalErr(err)
}
select {
case <-a.prefs:
default:
}
a.prefs <- prefs
}
func (a *App) setURL(url string) {
select {
case <-a.browseURLs:
default:
}
a.browseURLs <- url
}
func (a *App) runUI() error {
w := app.NewWindow()
ui, err := newUI(a.store)
if err != nil {
return err
}
var ops op.Ops
state := new(clientState)
var (
// activity is the most recent Android Activity reference as reported
// by Gio ViewEvents.
activity jni.Object
// files is list of files from the most recent file sharing intent.
files []File
)
deleteActivityRef := func() {
if activity == 0 {
return
}
jni.Do(a.jvm, func(env *jni.Env) error {
jni.DeleteGlobalRef(env, activity)
return nil
})
activity = 0
}
defer deleteActivityRef()
for {
select {
case <-onVPNClosed:
requestBackend(ConnectEvent{Enable: false})
case tok := <-onGoogleToken:
ui.signinType = noSignin
if tok != "" {
requestBackend(OAuth2Event{
Token: &tailcfg.Oauth2Token{
AccessToken: tok,
TokenType: ipn.GoogleIDTokenType,
},
})
} else {
// Warn about possible debug certificate.
if !a.isReleaseSigned() {
ui.ShowMessage("Google Sign-In failed because the app is not signed for Play Store")
w.Invalidate()
}
}
case p := <-a.prefs:
ui.enabled.Value = p.WantRunning
ui.runningExit = p.AdvertisesExitNode()
ui.exitLAN.Value = p.ExitNodeAllowLANAccess
w.Invalidate()
case url := <-a.browseURLs:
ui.signinType = noSignin
if a.isTV() {
ui.ShowQRCode(url)
} else {
state.browseURL = url
}
w.Invalidate()
a.updateState(activity, state)
case newState := <-a.netStates:
oldState := state.backend.State
state.backend = newState
a.updateState(activity, state)
w.Invalidate()
if activity != 0 {
newState := state.backend.State
// Start VPN if we just logged in.
if oldState <= ipn.Stopped && newState > ipn.Stopped {
if err := a.prepareVPN(activity); err != nil {
fatalErr(err)
}
}
}
case <-onVPNPrepared:
if state.backend.State > ipn.Stopped {
if err := a.callVoidMethod(a.appCtx, "startVPN", "()V"); err != nil {
return err
}
if activity != 0 {
if err := a.callVoidMethod(a.appCtx, "requestWriteStoragePermission", "(Landroid/app/Activity;)V", jni.Value(activity)); err != nil {
return err
}
}
}
case <-onVPNRevoked:
ui.ShowMessage("VPN access denied or another VPN service is always-on")
w.Invalidate()
case files = <-onFileShare:
ui.ShowShareDialog()
w.Invalidate()
case t := <-a.targetsLoaded:
ui.FillShareDialog(t.Targets, t.Err)
w.Invalidate()
case <-a.invalidates:
w.Invalidate()
case e := <-w.Events():
switch e := e.(type) {
case app.ViewEvent:
deleteActivityRef()
view := jni.Object(e.View)
if view == 0 {
break
}
activity = a.contextForView(view)
w.Invalidate()
a.attachPeer(activity)
if state.backend.State > ipn.Stopped {
if err := a.prepareVPN(activity); err != nil {
return err
}
}
case system.DestroyEvent:
return e.Err
case *system.CommandEvent:
if e.Type == system.CommandBack {
if ui.onBack() {
e.Cancel = true
w.Invalidate()
}
}
case system.FrameEvent:
ins := e.Insets
e.Insets = system.Insets{}
gtx := layout.NewContext(&ops, e)
events := ui.layout(gtx, ins, state)
e.Frame(gtx.Ops)
a.processUIEvents(w, events, activity, state, files)
}
}
}
}
func (a *App) isTV() bool {
var istv bool
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "isTV", "()Z")
b, err := jni.CallBooleanMethod(env, a.appCtx, m)
istv = b
return err
})
if err != nil {
fatalErr(err)
}
return istv
}
// isReleaseSigned reports whether the app is signed with a release
// signature.
func (a *App) isReleaseSigned() bool {
var cert []byte
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "getPackageCertificate", "()[B")
str, err := jni.CallObjectMethod(env, a.appCtx, m)
if err != nil {
return err
}
cert = jni.GetByteArrayElements(env, jni.ByteArray(str))
return nil
})
if err != nil {
fatalErr(err)
}
h := sha1.New()
h.Write(cert)
fingerprint := h.Sum(nil)
hex := fmt.Sprintf("%x", fingerprint)
// Strip colons and convert to lower case to ease comparing.
wantFingerprint := strings.ReplaceAll(strings.ToLower(releaseCertFingerprint), ":", "")
return hex == wantFingerprint
}
// attachPeer registers an Android Fragment instance for
// handling onActivityResult callbacks.
func (a *App) attachPeer(act jni.Object) {
err := a.callVoidMethod(a.appCtx, "attachPeer", "(Landroid/app/Activity;)V", jni.Value(act))
if err != nil {
fatalErr(err)
}
}
func (a *App) updateState(act jni.Object, state *clientState) {
if act != 0 && state.browseURL != "" {
a.browseToURL(act, state.browseURL)
state.browseURL = ""
}
netmap := state.backend.NetworkMap
var (
peers []*tailcfg.Node
myID tailcfg.UserID
)
if netmap != nil {
peers = netmap.Peers
myID = netmap.User
}
// Split into sections.
users := make(map[tailcfg.UserID]struct{})
var uiPeers []UIPeer
for _, p := range peers {
if q := state.query; q != "" {
// Filter peers according to search query.
host := strings.ToLower(p.Hostinfo.Hostname())
name := strings.ToLower(p.Name)
var addr string
if len(p.Addresses) > 0 {
addr = p.Addresses[0].IP().String()
}
if !strings.Contains(host, q) && !strings.Contains(name, q) && !strings.Contains(addr, q) {
continue
}
}
users[p.User] = struct{}{}
uiPeers = append(uiPeers, UIPeer{
Owner: p.User,
Peer: p,
})
}
// Add section (user) headers.
for u := range users {
name := netmap.UserProfiles[u].DisplayName
name = strings.ToUpper(name)
uiPeers = append(uiPeers, UIPeer{Owner: u, Name: name})
}
sort.Slice(uiPeers, func(i, j int) bool {
lhs, rhs := uiPeers[i], uiPeers[j]
if lu, ru := lhs.Owner, rhs.Owner; ru != lu {
// Sort own peers first.
if lu == myID {
return true
}
if ru == myID {
return false
}
return lu < ru
}
lp, rp := lhs.Peer, rhs.Peer
// Sort headers first.
if lp == nil {
return true
}
if rp == nil {
return false
}
lName := lp.DisplayName(lp.User == myID)
rName := rp.DisplayName(rp.User == myID)
return lName < rName || lName == rName && lp.ID < rp.ID
})
state.Peers = uiPeers
}
func (a *App) prepareVPN(act jni.Object) error {
return a.callVoidMethod(a.appCtx, "prepareVPN", "(Landroid/app/Activity;I)V",
jni.Value(act), jni.Value(requestPrepareVPN))
}
func requestBackend(e UIEvent) {
go func() {
backendEvents <- e
}()
}
func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState, files []File) {
for _, e := range events {
switch e := e.(type) {
case ReauthEvent:
method, _ := a.store.ReadString(loginMethodPrefKey, loginMethodWeb)
switch method {
case loginMethodGoogle:
a.googleSignIn(act)
default:
requestBackend(WebAuthEvent{})
}
case BugEvent:
backendLogID, _ := a.logIDPublicAtomic.Load().(string)
logMarker := fmt.Sprintf("BUG-%v-%v-%v", backendLogID, time.Now().UTC().Format("20060102150405Z"), randHex(8))
log.Printf("user bugreport: %s", logMarker)
w.WriteClipboard(logMarker)
case BeExitNodeEvent:
requestBackend(e)
case ExitAllowLANEvent:
requestBackend(e)
case WebAuthEvent:
a.store.WriteString(loginMethodPrefKey, loginMethodWeb)
requestBackend(e)
case SetLoginServerEvent:
a.store.WriteString(customLoginServerPrefKey, e.URL)
requestBackend(e)
case LogoutEvent:
a.signOut()
requestBackend(e)
case ConnectEvent:
requestBackend(e)
case RouteAllEvent:
requestBackend(e)
case CopyEvent:
w.WriteClipboard(e.Text)
case GoogleAuthEvent:
a.store.WriteString(loginMethodPrefKey, loginMethodGoogle)
a.googleSignIn(act)
case SearchEvent:
state.query = strings.ToLower(e.Query)
a.updateState(act, state)
case FileSendEvent:
a.sendFiles(e, files)
}
}
}
func (a *App) sendFiles(e FileSendEvent, files []File) {
go func() {
var totalSize int64
for _, f := range files {
totalSize += f.Size
}
if totalSize == 0 {
totalSize = 1
}
var totalSent int64
progress := func(n int64) {
totalSent += n
e.Updates(FileSendInfo{
State: FileSendTransferring,
Progress: float64(totalSent) / float64(totalSize),
})
a.invalidate()
}
defer a.invalidate()
for _, f := range files {
if err := a.sendFile(e.Context, e.Target, f, progress); err != nil {
if errors.Is(err, context.Canceled) {
return
}
e.Updates(FileSendInfo{
State: FileSendFailed,
})
return
}
}
e.Updates(FileSendInfo{
State: FileSendComplete,
})
}()
}
func (a *App) invalidate() {
select {
case a.invalidates <- struct{}{}:
default:
}
}
func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget, f File, progress func(n int64)) error {
var body io.Reader
switch f.Type {
case FileTypeText:
body = strings.NewReader(f.Text)
case FileTypeURI:
f, err := a.openURI(f.URI, "r")
if err != nil {
return err
}
defer f.Close()
body = f
default:
panic("unknown file type")
}
body = &progressReader{r: body, size: f.Size, progress: progress}
dstURL := target.PeerAPIURL + "/v0/put/" + url.PathEscape(f.Name)
req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, body)
if err != nil {
return err
}
req.ContentLength = f.Size
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("PUT failed: %s", res.Status)
}
return nil
}
// progressReader wraps an io.Reader to call a progress function
// on every non-zero Read.
type progressReader struct {
r io.Reader
bytes int64
size int64
eof bool
progress func(n int64)
}
func (r *progressReader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
// The request body may be read after http.Client.Do returns, see
// https://github.com/golang/go/issues/30597. Don't update progress if the
// file has been read.
r.eof = r.eof || errors.Is(err, io.EOF)
if !r.eof && r.bytes < r.size {
r.progress(int64(n))
r.bytes += int64(n)
}
return n, err
}
func (a *App) signOut() {
if googleClass == 0 {
return
}
err := jni.Do(a.jvm, func(env *jni.Env) error {
m := jni.GetStaticMethodID(env, googleClass,
"googleSignOut", "(Landroid/content/Context;)V")
return jni.CallStaticVoidMethod(env, googleClass, m, jni.Value(a.appCtx))
})
if err != nil {
fatalErr(err)
}
}
func (a *App) googleSignIn(act jni.Object) {
if act == 0 || googleClass == 0 {
return
}
err := jni.Do(a.jvm, func(env *jni.Env) error {
sid := jni.JavaString(env, serverOAuthID)
m := jni.GetStaticMethodID(env, googleClass,
"googleSignIn", "(Landroid/app/Activity;Ljava/lang/String;I)V")
return jni.CallStaticVoidMethod(env, googleClass, m,
jni.Value(act), jni.Value(sid), jni.Value(requestSignin))
})
if err != nil {
fatalErr(err)
}
}
func (a *App) browseToURL(act jni.Object, url string) {
if act == 0 {
return
}
err := jni.Do(a.jvm, func(env *jni.Env) error {
jurl := jni.JavaString(env, url)
return a.callVoidMethod(a.appCtx, "showURL", "(Landroid/app/Activity;Ljava/lang/String;)V", jni.Value(act), jni.Value(jurl))
})
if err != nil {
fatalErr(err)
}
}
func (a *App) callVoidMethod(obj jni.Object, name, sig string, args ...jni.Value) error {
if obj == 0 {
panic("invalid object")
}
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, obj)
m := jni.GetMethodID(env, cls, name, sig)
return jni.CallVoidMethod(env, obj, m, args...)
})
}
// activityForView calls View.getContext and returns a global
// reference to the result.
func (a *App) contextForView(view jni.Object) jni.Object {
if view == 0 {
panic("invalid object")
}
var ctx jni.Object
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, view)
m := jni.GetMethodID(env, cls, "getContext", "()Landroid/content/Context;")
var err error
ctx, err = jni.CallObjectMethod(env, view, m)
ctx = jni.NewGlobalRef(env, ctx)
return err
})
if err != nil {
panic(err)
}
return ctx
}
// Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]interfaces.Interface, error) {
var ifaceString string
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "getInterfacesAsString", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, a.appCtx, m)
ifaceString = jni.GoString(env, jni.String(n))
return err
})
var ifaces []interfaces.Interface
if err != nil {
return ifaces, err
}
for _, iface := range strings.Split(ifaceString, "\n") {
// Example of the strings we're processing:
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// mnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
if strings.TrimSpace(iface) == "" {
continue
}
fields := strings.Split(iface, "|")
if len(fields) != 2 {
log.Printf("getInterfaces: unable to split %q", iface)
continue
}
var name string
var index, mtu int
var up, broadcast, loopback, pointToPoint, multicast bool
_, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t",
&name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast)
if err != nil {
log.Printf("getInterfaces: unable to parse %q: %v", iface, err)
continue
}
newIf := interfaces.Interface{
Interface: &net.Interface{
Name: name,
Index: index,
MTU: mtu,
},
AltAddrs: []net.Addr{}, // non-nil to avoid Go using netlink
}
if up {
newIf.Flags |= net.FlagUp
}
if broadcast {
newIf.Flags |= net.FlagBroadcast
}
if loopback {
newIf.Flags |= net.FlagLoopback
}
if pointToPoint {
newIf.Flags |= net.FlagPointToPoint
}
if multicast {
newIf.Flags |= net.FlagMulticast
}
addrs := strings.Trim(fields[1], " \n")
for _, addr := range strings.Split(addrs, " ") {
ip, err := netaddr.ParseIPPrefix(addr)
if err == nil {
newIf.AltAddrs = append(newIf.AltAddrs, ip.IPNet())
}
}
ifaces = append(ifaces, newIf)
}
return ifaces, nil
}
func fatalErr(err error) {
// TODO: expose in UI.
log.Printf("fatal error: %v", err)
}
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
================================================
FILE: cmd/tailscale/multitun.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"os"
"golang.zx2c4.com/wireguard/tun"
)
// multiTUN implements a tun.Device that supports multiple
// underlying devices. This is necessary because Android VPN devices
// have static configurations and wgengine.NewUserspaceEngine
// assumes a single static tun.Device.
type multiTUN struct {
// devices is for adding new devices.
devices chan tun.Device
// event is the combined event channel from all active devices.
events chan tun.Event
close chan struct{}
closeErr chan error
reads chan ioRequest
writes chan ioRequest
flushes chan chan error
mtus chan chan mtuReply
names chan chan nameReply
shutdowns chan struct{}
shutdownDone chan struct{}
}
// tunDevice wraps and drives a single run.Device.
type tunDevice struct {
dev tun.Device
// close closes the device.
close chan struct{}
closeDone chan error
// readDone is notified when the read goroutine is done.
readDone chan struct{}
}
type ioRequest struct {
data []byte
offset int
reply chan<- ioReply
}
type ioReply struct {
bytes int
err error
}
type mtuReply struct {
mtu int
err error
}
type nameReply struct {
name string
err error
}
func newTUNDevices() *multiTUN {
d := &multiTUN{
devices: make(chan tun.Device),
events: make(chan tun.Event),
close: make(chan struct{}),
closeErr: make(chan error),
reads: make(chan ioRequest),
writes: make(chan ioRequest),
flushes: make(chan chan error),
mtus: make(chan chan mtuReply),
names: make(chan chan nameReply),
shutdowns: make(chan struct{}),
shutdownDone: make(chan struct{}),
}
go d.run()
return d
}
func (d *multiTUN) run() {
var devices []*tunDevice
// readDone is the readDone channel of the device being read from.
var readDone chan struct{}
// runDone is the closeDone channel of the device being written to.
var runDone chan error
for {
select {
case <-readDone:
// The oldest device has reached EOF, replace it.
n := copy(devices, devices[1:])
devices = devices[:n]
if len(devices) > 0 {
// Start reading from the next device.
dev := devices[0]
readDone = dev.readDone
go d.readFrom(dev)
}
case <-runDone:
// A device completed runDevice, replace it.
if len(devices) > 0 {
dev := devices[len(devices)-1]
runDone = dev.closeDone
go d.runDevice(dev)
}
case <-d.shutdowns:
// Shut down all devices.
for _, dev := range devices {
close(dev.close)
<-dev.closeDone
<-dev.readDone
}
devices = nil
d.shutdownDone <- struct{}{}
case <-d.close:
var derr error
for _, dev := range devices {
if err := <-dev.closeDone; err != nil {
derr = err
}
}
d.closeErr <- derr
return
case dev := <-d.devices:
if len(devices) > 0 {
// Ask the most recent device to stop.
prev := devices[len(devices)-1]
close(prev.close)
}
wrap := &tunDevice{
dev: dev,
close: make(chan struct{}),
closeDone: make(chan error),
readDone: make(chan struct{}, 1),
}
if len(devices) == 0 {
// Start using this first device.
readDone = wrap.readDone
go d.readFrom(wrap)
runDone = wrap.closeDone
go d.runDevice(wrap)
}
devices = append(devices, wrap)
case f := <-d.flushes:
var err error
if len(devices) > 0 {
dev := devices[len(devices)-1]
err = dev.dev.Flush()
}
f <- err
case m := <-d.mtus:
r := mtuReply{mtu: defaultMTU}
if len(devices) > 0 {
dev := devices[len(devices)-1]
r.mtu, r.err = dev.dev.MTU()
}
m <- r
case n := <-d.names:
var r nameReply
if len(devices) > 0 {
dev := devices[len(devices)-1]
r.name, r.err = dev.dev.Name()
}
n <- r
}
}
}
func (d *multiTUN) readFrom(dev *tunDevice) {
defer func() {
dev.readDone <- struct{}{}
}()
for {
select {
case r := <-d.reads:
n, err := dev.dev.Read(r.data, r.offset)
stop := false
if err != nil {
select {
case <-dev.close:
stop = true
err = nil
default:
}
}
r.reply <- ioReply{n, err}
if stop {
return
}
case <-d.close:
return
}
}
}
func (d *multiTUN) runDevice(dev *tunDevice) {
defer func() {
// The documentation for https://developer.android.com/reference/android/net/VpnService.Builder#establish()
// states that "Therefore, after draining the old file
// descriptor...", but pending Reads are never unblocked
// when a new descriptor is created.
//
// Close it instead and hope that no packets are lost.
dev.closeDone <- dev.dev.Close()
}()
// Pump device events.
go func() {
for {
select {
case e := <-dev.dev.Events():
d.events <- e
case <-dev.close:
return
}
}
}()
for {
select {
case w := <-d.writes:
n, err := dev.dev.Write(w.data, w.offset)
w.reply <- ioReply{n, err}
case <-dev.close:
// Device closed.
return
case <-d.close:
// Multi-device closed.
return
}
}
}
func (d *multiTUN) add(dev tun.Device) {
d.devices <- dev
}
func (d *multiTUN) File() *os.File {
// The underlying file descriptor is not constant on Android.
// Let's hope no-one uses it.
panic("not available on Android")
}
func (d *multiTUN) Read(data []byte, offset int) (int, error) {
r := make(chan ioReply)
d.reads <- ioRequest{data, offset, r}
rep := <-r
return rep.bytes, rep.err
}
func (d *multiTUN) Write(data []byte, offset int) (int, error) {
r := make(chan ioReply)
d.writes <- ioRequest{data, offset, r}
rep := <-r
return rep.bytes, rep.err
}
func (d *multiTUN) Flush() error {
r := make(chan error)
d.flushes <- r
return <-r
}
func (d *multiTUN) MTU() (int, error) {
r := make(chan mtuReply)
d.mtus <- r
rep := <-r
return rep.mtu, rep.err
}
func (d *multiTUN) Name() (string, error) {
r := make(chan nameReply)
d.names <- r
rep := <-r
return rep.name, rep.err
}
func (d *multiTUN) Events() chan tun.Event {
return d.events
}
func (d *multiTUN) Shutdown() {
d.shutdowns <- struct{}{}
<-d.shutdownDone
}
func (d *multiTUN) Close() error {
close(d.close)
return <-d.closeErr
}
================================================
FILE: cmd/tailscale/pprof.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build pprof
// +build pprof
package main
import (
"net/http"
_ "net/http/pprof"
)
func init() {
go func() {
http.ListenAndServe(":6060", nil)
}()
}
================================================
FILE: cmd/tailscale/store.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"encoding/base64"
"tailscale.com/ipn"
"github.com/tailscale/tailscale-android/jni"
)
// stateStore is the Go interface for a persistent storage
// backend by androidx.security.crypto.EncryptedSharedPreferences (see
// App.java).
type stateStore struct {
jvm *jni.JVM
// appCtx is the global Android app context.
appCtx jni.Object
// Cached method ids on appCtx.
encrypt jni.MethodID
decrypt jni.MethodID
}
func newStateStore(jvm *jni.JVM, appCtx jni.Object) *stateStore {
s := &stateStore{
jvm: jvm,
appCtx: appCtx,
}
jni.Do(jvm, func(env *jni.Env) error {
appCls := jni.GetObjectClass(env, appCtx)
s.encrypt = jni.GetMethodID(
env, appCls,
"encryptToPref", "(Ljava/lang/String;Ljava/lang/String;)V",
)
s.decrypt = jni.GetMethodID(
env, appCls,
"decryptFromPref", "(Ljava/lang/String;)Ljava/lang/String;",
)
return nil
})
return s
}
func prefKeyFor(id ipn.StateKey) string {
return "statestore-" + string(id)
}
func (s *stateStore) ReadString(key string, def string) (string, error) {
data, err := s.read(key)
if err != nil {
return def, err
}
if data == nil {
return def, nil
}
return string(data), nil
}
func (s *stateStore) WriteString(key string, val string) error {
return s.write(key, []byte(val))
}
func (s *stateStore) ReadBool(key string, def bool) (bool, error) {
data, err := s.read(key)
if err != nil {
return def, err
}
if data == nil {
return def, nil
}
return string(data) == "true", nil
}
func (s *stateStore) WriteBool(key string, val bool) error {
data := []byte("false")
if val {
data = []byte("true")
}
return s.write(key, data)
}
func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) {
state, err := s.read(prefKeyFor(id))
if err != nil {
return nil, err
}
if state == nil {
return nil, ipn.ErrStateNotExist
}
return state, nil
}
func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error {
prefKey := prefKeyFor(id)
return s.write(prefKey, bs)
}
func (s *stateStore) read(key string) ([]byte, error) {
var data []byte
err := jni.Do(s.jvm, func(env *jni.Env) error {
jfile := jni.JavaString(env, key)
plain, err := jni.CallObjectMethod(env, s.appCtx, s.decrypt,
jni.Value(jfile))
if err != nil {
return err
}
b64 := jni.GoString(env, jni.String(plain))
if b64 == "" {
return nil
}
data, err = base64.RawStdEncoding.DecodeString(b64)
return err
})
return data, err
}
func (s *stateStore) write(key string, value []byte) error {
bs64 := base64.RawStdEncoding.EncodeToString(value)
err := jni.Do(s.jvm, func(env *jni.Env) error {
jfile := jni.JavaString(env, key)
jplain := jni.JavaString(env, bs64)
err := jni.CallVoidMethod(env, s.appCtx, s.encrypt,
jni.Value(jfile), jni.Value(jplain))
if err != nil {
return err
}
return nil
})
return err
}
================================================
FILE: cmd/tailscale/tools.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build tools
// +build tools
package main
import (
_ "gioui.org/cmd/gogio"
)
================================================
FILE: cmd/tailscale/ui.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"time"
"gioui.org/f32"
"gioui.org/font/opentype"
"gioui.org/io/pointer"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/exp/shiny/materialdesign/icons"
"inet.af/netaddr"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
_ "embed"
"eliasnaur.com/font/roboto/robotobold"
"eliasnaur.com/font/roboto/robotoregular"
_ "image/png"
)
type UI struct {
theme *material.Theme
store *stateStore
// root is the scrollable list of the main UI.
root layout.List
// enabled is the switch for enabling or disabling the VPN.
enabled widget.Bool
search widget.Editor
exitLAN widget.Bool
// webSigin is the button for the web-based sign-in flow.
webSignin widget.Clickable
// googleSignin is the button for native Google Sign-in.
googleSignin widget.Clickable
// openExitDialog opens the exit node picker.
openExitDialog widget.Clickable
signinType signinType
setLoginServer bool
loginServer widget.Editor
loginServerSave widget.Clickable
loginServerCancel widget.Clickable
self widget.Clickable
peers []widget.Clickable
// exitDialog is state for the exit node dialog.
exitDialog struct {
show bool
dismiss Dismiss
exits widget.Enum
list layout.List
}
runningExit bool // are we an exit node now?
qr struct {
show bool
op paint.ImageOp
}
intro struct {
list layout.List
start widget.Clickable
show bool
}
menu struct {
open widget.Clickable
dismiss Dismiss
show bool
useLoginServer widget.Clickable
copy widget.Clickable
reauth widget.Clickable
bug widget.Clickable
beExit widget.Clickable
exits widget.Clickable
logout widget.Clickable
}
// The current pop-up message, if any
message struct {
text string
// t0 is the time when the most recent message appeared.
t0 time.Time
}
shareDialog struct {
show bool
dismiss Dismiss
list layout.List
// peers are the nodes ready to receive files.
targets []shareTarget
loaded bool
error error
}
icons struct {
search *widget.Icon
more *widget.Icon
exitStatus *widget.Icon
done *widget.Icon
error *widget.Icon
logo paint.ImageOp
google paint.ImageOp
}
}
type shareTarget struct {
btn widget.Clickable
target *apitype.FileTarget
info FileSendInfo
cancel func()
updates <-chan FileSendInfo
}
type signinType uint8
// An UIPeer is either a peer or a section header
// with the user information.
type UIPeer struct {
// Owner of the peer.
Owner tailcfg.UserID
// Name is the owner's name in all caps (for section headers).
Name string
// Peer is nil for section headers.
Peer *tailcfg.Node
}
// menuItem describes an item in a popup menu.
type menuItem struct {
title string
btn *widget.Clickable
}
const (
headerColor = 0x496495
infoColor = 0x3a517b
white = 0xffffff
)
const (
keyShowIntro = "ui.showintro"
)
const (
noSignin signinType = iota
webSignin
googleSignin
)
type (
C = layout.Context
D = layout.Dimensions
)
var (
//go:embed tailscale.png
tailscaleLogo []byte
//go:embed google.png
googleLogo []byte
)
func newUI(store *stateStore) (*UI, error) {
searchIcon, err := widget.NewIcon(icons.ActionSearch)
if err != nil {
return nil, err
}
moreIcon, err := widget.NewIcon(icons.NavigationMoreVert)
if err != nil {
return nil, err
}
exitStatus, err := widget.NewIcon(icons.NavigationMenu)
if err != nil {
return nil, err
}
doneIcon, err := widget.NewIcon(icons.ActionCheckCircle)
if err != nil {
return nil, err
}
errorIcon, err := widget.NewIcon(icons.AlertErrorOutline)
if err != nil {
return nil, err
}
logo, _, err := image.Decode(bytes.NewReader(tailscaleLogo))
if err != nil {
return nil, err
}
google, _, err := image.Decode(bytes.NewReader(googleLogo))
if err != nil {
return nil, err
}
face, err := opentype.Parse(robotoregular.TTF)
if err != nil {
panic(fmt.Sprintf("failed to parse font: %v", err))
}
faceBold, err := opentype.Parse(robotobold.TTF)
if err != nil {
panic(fmt.Sprintf("failed to parse font: %v", err))
}
fonts := []text.FontFace{
{Font: text.Font{Typeface: "Roboto"}, Face: face},
{Font: text.Font{Typeface: "Roboto", Weight: text.Bold}, Face: faceBold},
}
ui := &UI{
theme: material.NewTheme(fonts),
store: store,
}
ui.intro.show, _ = store.ReadBool(keyShowIntro, true)
ui.icons.search = searchIcon
ui.icons.more = moreIcon
ui.icons.exitStatus = exitStatus
ui.icons.done = doneIcon
ui.icons.error = errorIcon
ui.icons.logo = paint.NewImageOp(logo)
ui.icons.google = paint.NewImageOp(google)
ui.root.Axis = layout.Vertical
ui.intro.list.Axis = layout.Vertical
ui.search.SingleLine = true
ui.loginServer.SingleLine = true
ui.exitDialog.list.Axis = layout.Vertical
ui.shareDialog.list.Axis = layout.Vertical
return ui, nil
}
func mulAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
c.A = uint8(uint32(c.A) * uint32(alpha) / 0xff)
return c
}
func (ui *UI) onBack() bool {
b := ui.activeDialog()
if b == nil {
return false
}
*b = false
return true
}
func (ui *UI) activeDialog() *bool {
switch {
case ui.qr.show:
return &ui.qr.show
case ui.menu.show:
return &ui.menu.show
case ui.shareDialog.show:
return &ui.shareDialog.show
case ui.exitDialog.show:
return &ui.exitDialog.show
}
return nil
}
func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent {
// "Get started".
if ui.intro.show {
if ui.intro.start.Clicked() {
ui.store.WriteBool(keyShowIntro, false)
ui.intro.show = false
}
ui.layoutIntro(gtx, sysIns)
return nil
}
var events []UIEvent
if ui.enabled.Changed() {
events = append(events, ConnectEvent{Enable: ui.enabled.Value})
}
for _, e := range ui.search.Events() {
if _, ok := e.(widget.ChangeEvent); ok {
events = append(events, SearchEvent{Query: ui.search.Text()})
break
}
}
for ui.menu.open.Clicked() {
ui.menu.show = !ui.menu.show
}
netmap := state.backend.NetworkMap
var (
localName, localAddr string
expiry time.Time
userID tailcfg.UserID
exitID tailcfg.StableNodeID
)
if netmap != nil {
userID = netmap.User
expiry = netmap.Expiry
localName = netmap.SelfNode.DisplayName(false)
if addrs := netmap.Addresses; len(addrs) > 0 {
localAddr = addrs[0].IP().String()
}
}
if p := state.backend.Prefs; p != nil {
exitID = p.ExitNodeID
}
if d := &ui.exitDialog; d.show {
if newID := tailcfg.StableNodeID(d.exits.Value); newID != exitID {
d.show = false
events = append(events, RouteAllEvent{newID})
}
} else {
d.exits.Value = string(exitID)
}
if ui.exitLAN.Changed() {
events = append(events, ExitAllowLANEvent(ui.exitLAN.Value))
}
if ui.googleSignin.Clicked() {
ui.signinType = googleSignin
events = append(events, GoogleAuthEvent{})
}
if ui.webSignin.Clicked() {
ui.signinType = webSignin
events = append(events, WebAuthEvent{})
}
if ui.loginServerSave.Clicked() {
text := ui.loginServer.Text()
ui.showMessage(gtx, "Login server saved, relaunch the app")
events = append(events, SetLoginServerEvent{URL: text})
}
if ui.loginServerCancel.Clicked() {
ui.setLoginServer = false
}
if ui.menuClicked(&ui.menu.useLoginServer) {
ui.setLoginServer = true
savedLoginServer, _ := ui.store.ReadString(customLoginServerPrefKey, "")
ui.loginServer.SetText(savedLoginServer)
}
if ui.menuClicked(&ui.menu.copy) && localAddr != "" {
events = append(events, CopyEvent{Text: localAddr})
ui.showCopied(gtx, localAddr)
}
if ui.menuClicked(&ui.menu.reauth) {
events = append(events, ReauthEvent{})
}
if ui.menuClicked(&ui.menu.bug) {
events = append(events, BugEvent{})
ui.showCopied(gtx, "bug report marker to clipboard")
}
if ui.menuClicked(&ui.menu.beExit) {
ui.runningExit = !ui.runningExit
events = append(events, BeExitNodeEvent(ui.runningExit))
if ui.runningExit {
ui.showMessage(gtx, "Running exit node")
} else {
ui.showMessage(gtx, "Stopped running exit node")
}
}
if ui.menuClicked(&ui.menu.exits) || ui.openExitDialog.Clicked() {
ui.exitDialog.show = true
}
if ui.menuClicked(&ui.menu.logout) {
events = append(events, LogoutEvent{})
}
for i := range ui.shareDialog.targets {
t := &ui.shareDialog.targets[i]
select {
case t.info = <-t.updates:
default:
}
if !t.btn.Clicked() {
continue
}
switch t.info.State {
case FileSendTransferring, FileSendConnecting:
t.cancel()
t.info.State = FileSendNotStarted
t.updates = nil
continue
}
t.info = FileSendInfo{
State: FileSendConnecting,
}
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
updates := make(chan FileSendInfo, 1)
t.updates = updates
events = append(events, FileSendEvent{
Target: t.target,
Context: ctx,
Updates: func(info FileSendInfo) {
select {
case <-updates:
default:
}
updates <- info
},
})
}
for len(ui.peers) < len(state.Peers) {
ui.peers = append(ui.peers, widget.Clickable{})
}
if max := len(state.Peers); len(ui.peers) > max {
ui.peers = ui.peers[:max]
}
const numHeaders = 6
n := numHeaders + len(state.Peers)
needsLogin := state.backend.State == ipn.NeedsLogin
if !needsLogin {
ui.qr.show = false
}
rootGtx := gtx
if ui.activeDialog() != nil {
rootGtx.Queue = nil
}
ui.root.Layout(rootGtx, n, func(gtx C, idx int) D {
var in layout.Inset
if idx == n-1 {
// The last list element includes the bottom system
// inset.
in.Bottom = sysIns.Bottom
}
return in.Layout(gtx, func(gtx C) D {
switch idx {
case 0:
return ui.layoutTop(gtx, sysIns, &state.backend)
case 1:
if netmap == nil || state.backend.State < ipn.Stopped {
return D{}
}
for ui.self.Clicked() {
events = append(events, CopyEvent{Text: localAddr})
ui.showCopied(gtx, localAddr)
}
return ui.layoutLocal(gtx, sysIns, localName, localAddr)
case 2:
return ui.layoutExitStatus(gtx, &state.backend)
case 3:
if state.backend.State < ipn.Stopped {
return D{}
}
return ui.layoutSearchbar(gtx, sysIns)
case 4:
if !needsLogin || state.backend.LostInternet {
return D{}
}
return ui.layoutSignIn(gtx, &state.backend)
case 5:
if !state.backend.LostInternet {
return D{}
}
return ui.layoutDisconnected(gtx)
default:
if needsLogin {
return D{}
}
pidx := idx - numHeaders
p := &state.Peers[pidx]
if p.Peer == nil {
name := p.Name
if p.Owner == userID {
name = "MY DEVICES"
}
return ui.layoutSection(gtx, sysIns, name)
} else {
clk := &ui.peers[pidx]
if clk.Clicked() {
if addrs := p.Peer.Addresses; len(addrs) > 0 {
a := addrs[0].IP().String()
events = append(events, CopyEvent{Text: a})
ui.showCopied(gtx, a)
}
}
return ui.layoutPeer(gtx, sysIns, p, userID, clk)
}
}
})
})
ui.layoutExitNodeDialog(gtx, sysIns, state.backend.Exits)
ui.layoutShareDialog(gtx, sysIns)
// Popup messages.
ui.layoutMessage(gtx, sysIns)
// 3-dots menu.
if ui.menu.show {
ui.layoutMenu(gtx, sysIns, expiry, exitID != "" || len(state.backend.Exits) > 0, needsLogin)
}
if ui.qr.show {
ui.layoutQR(gtx, sysIns)
}
return events
}
func (ui *UI) layoutQR(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max)
return layout.Center.Layout(gtx, func(gtx C) D {
return drawImage(gtx, ui.qr.op, unit.Dp(300))
})
}
func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) {
ui.shareDialog.error = err
ui.shareDialog.loaded = true
targetSet := make(map[tailcfg.NodeID]int)
if ui.shareDialog.show {
// Update rather than replace list.
for i, t := range ui.shareDialog.targets {
targetSet[t.target.Node.ID] = i
}
} else {
ui.shareDialog.targets = nil
}
for _, t := range targets {
if i, ok := targetSet[t.Node.ID]; ok {
ui.shareDialog.targets[i].target = t
} else {
ui.shareDialog.targets = append(ui.shareDialog.targets, shareTarget{target: t})
}
}
}
func (ui *UI) ShowShareDialog() {
ui.shareDialog.show = true
}
func (ui *UI) ShowMessage(msg string) {
ui.message.text = msg
ui.message.t0 = time.Now()
}
func (ui *UI) ShowQRCode(url string) {
ui.qr.show = true
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
fatalErr(err)
return
}
ui.qr.op = paint.NewImageOp(q.Image(512))
}
// Dismiss is a widget that detects pointer presses.
type Dismiss struct {
}
func (d *Dismiss) Add(gtx layout.Context, color color.NRGBA) {
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
pointer.InputOp{Tag: d, Types: pointer.Press}.Add(gtx.Ops)
paint.Fill(gtx.Ops, color)
}
func (d *Dismiss) Dismissed(gtx layout.Context) bool {
for _, e := range gtx.Events(d) {
if e, ok := e.(pointer.Event); ok {
if e.Type == pointer.Press {
return true
}
}
}
return false
}
func (ui *UI) layoutExitStatus(gtx layout.Context, state *BackendState) layout.Dimensions {
var bg color.NRGBA
var text string
switch state.ExitStatus {
case ExitNone:
return D{}
case ExitOffline:
text = "Exit node offline"
bg = rgb(0xc65835)
case ExitOnline:
text = "Using exit node"
bg = rgb(0x338b51)
}
paint.Fill(gtx.Ops, bg)
return material.Clickable(gtx, &ui.openExitDialog, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Top: unit.Dp(12),
Bottom: unit.Dp(12),
Right: unit.Dp(24),
Left: unit.Dp(24),
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
lbl := material.Body2(ui.theme, text)
lbl.Color = rgb(white)
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
node := material.Body2(ui.theme, state.Exit.Label)
node.Color = argb(0x88ffffff)
return node.Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx C) D {
return ui.icons.exitStatus.Layout(gtx, rgb(white))
}),
)
})
})
}
// layoutSignIn lays out the sign in button(s).
func (ui *UI) layoutSignIn(gtx layout.Context, state *BackendState) layout.Dimensions {
return layout.Inset{Top: unit.Dp(48), Left: unit.Dp(48), Right: unit.Dp(48)}.Layout(gtx, func(gtx C) D {
const (
textColor = 0x555555
)
border := widget.Border{Color: rgb(textColor), CornerRadius: unit.Dp(4), Width: unit.Px(1)}
if ui.setLoginServer {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0xe3e2ea), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1,
material.Editor(ui.theme, &ui.loginServer, "https://controlplane.tailscale.com").Layout,
),
)
})
})
})
}),
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
return border.Layout(gtx, func(gtx C) D {
button := material.Button(ui.theme, &ui.loginServerSave, "Save and restart")
button.Background = color.NRGBA{} // transparent
button.Color = rgb(textColor)
return button.Layout(gtx)
})
})
}),
layout.Rigid(func(gtx C) D {
return border.Layout(gtx, func(gtx C) D {
button := material.Button(ui.theme, &ui.loginServerCancel, "Cancel")
button.Background = color.NRGBA{} // transparent
button.Color = rgb(textColor)
return button.Layout(gtx)
})
}),
)
}
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
if !googleSignInEnabled() {
return D{}
}
return layout.Inset{Bottom: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
signin := material.ButtonLayout(ui.theme, &ui.googleSignin)
signin.Background = color.NRGBA{} // transparent
return ui.withLoader(gtx, ui.signinType == googleSignin, func(gtx C) D {
return border.Layout(gtx, func(gtx C) D {
if ui.signinType != noSignin {
gtx.Queue = nil
}
return signin.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.Y = gtx.Px(unit.Dp(48))
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Right: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
return drawImage(gtx, ui.icons.google, unit.Dp(16))
})
}),
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10)}.Layout(gtx, func(gtx C) D {
l := material.Body2(ui.theme, "Sign in with Google")
l.Color = rgb(textColor)
return l.Layout(gtx)
})
}),
)
})
})
})
})
}),
layout.Rigid(func(gtx C) D {
label := "Sign in with other"
if !googleSignInEnabled() {
label = "Sign in"
}
return ui.withLoader(gtx, ui.signinType == webSignin, func(gtx C) D {
return border.Layout(gtx, func(gtx C) D {
if ui.signinType != noSignin {
gtx.Queue = nil
}
signin := material.Button(ui.theme, &ui.webSignin, label)
signin.Background = color.NRGBA{} // transparent
signin.Color = rgb(textColor)
return signin.Layout(gtx)
})
})
}),
)
})
}
func (ui *UI) withLoader(gtx layout.Context, loading bool, w layout.Widget) layout.Dimensions {
cons := gtx.Constraints
return layout.Stack{Alignment: layout.W}.Layout(gtx,
layout.Stacked(func(gtx C) D {
gtx.Constraints = cons
return w(gtx)
}),
layout.Stacked(func(gtx C) D {
if !loading {
return D{}
}
return layout.Inset{Left: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min = image.Point{
X: gtx.Px(unit.Dp(16)),
}
return material.Loader(ui.theme).Layout(gtx)
})
}),
)
}
// layoutDisconnected lays out the "please connect to the internet"
// message.
func (ui *UI) layoutDisconnected(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
title := material.H6(ui.theme, "No internet connection")
title.Alignment = text.Middle
return title.Layout(gtx)
})
}),
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
msg := material.Body2(ui.theme, "Tailscale is paused while your device is offline. Please reconnect to the internet.")
msg.Alignment = text.Middle
return msg.Layout(gtx)
})
}),
)
})
}
// layoutIntro lays out the intro page with the logo and terms.
func (ui *UI) layoutIntro(gtx layout.Context, sysIns system.Insets) {
fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max)
ui.intro.list.Layout(gtx, 1, func(gtx C, idx int) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
// 9 dot logo.
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(80), Bottom: unit.Dp(48)}.Layout(gtx, func(gtx C) D {
return layout.N.Layout(gtx, func(gtx C) D {
sz := gtx.Px(unit.Dp(72))
drawLogo(gtx.Ops, sz)
return layout.Dimensions{Size: image.Pt(sz, sz)}
})
})
}),
// "tailscale".
layout.Rigid(func(gtx C) D {
return layout.N.Layout(gtx, func(gtx C) D {
return drawImage(gtx, ui.icons.logo, unit.Dp(200))
})
}),
// Terms.
layout.Rigid(func(gtx C) D {
return layout.Inset{
Top: unit.Dp(48),
Left: unit.Dp(32),
Right: unit.Dp(32),
}.Layout(gtx, func(gtx C) D {
terms := material.Body2(ui.theme, termsText)
terms.Color = rgb(0xbfbfbf)
terms.Alignment = text.Middle
return terms.Layout(gtx)
})
}),
// "Get started".
layout.Rigid(func(gtx C) D {
return layout.Inset{
Top: unit.Dp(16),
Left: unit.Dp(16),
Right: unit.Dp(16),
Bottom: unit.Add(gtx.Metric, sysIns.Bottom),
}.Layout(gtx, func(gtx C) D {
start := material.Button(ui.theme, &ui.intro.start, "Get Started")
start.Inset = layout.UniformInset(unit.Dp(16))
start.CornerRadius = unit.Dp(16)
start.Background = rgb(0x496495)
start.TextSize = unit.Sp(20)
return start.Layout(gtx)
})
}),
)
})
}
// menuClicked is like btn.Clicked, but also closes the menu if true.
func (ui *UI) menuClicked(btn *widget.Clickable) bool {
cl := btn.Clicked()
if cl {
ui.menu.show = false
}
return cl
}
// layoutShareDialog lays out the file sharing dialog shown on file send intents (ACTION_SEND, ACTION_SEND_MULTIPLE).
func (ui *UI) layoutShareDialog(gtx layout.Context, sysIns system.Insets) {
d := &ui.shareDialog
if d.dismiss.Dismissed(gtx) {
ui.shareDialog.show = false
}
if !d.show {
return
}
d.dismiss.Add(gtx, argb(0x66000000))
layout.Inset{
Top: unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)),
Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)),
Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)),
Left: unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)),
}.Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Px(unit.Dp(250))
gtx.Constraints.Max.X = gtx.Constraints.Min.X
return layoutDialog(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
// Header.
d := layout.Inset{
Top: unit.Dp(16),
Right: unit.Dp(20),
Left: unit.Dp(20),
Bottom: unit.Dp(16),
}.Layout(gtx, func(gtx C) D {
l := material.Body1(ui.theme, "Share via Tailscale")
l.Font.Weight = text.Bold
return l.Layout(gtx)
})
// Swallow clicks to title.
var c widget.Clickable
gtx.Queue = nil
return c.Layout(gtx, func(gtx C) D { return d })
}),
layout.Rigid(func(gtx C) D {
if d.loaded {
return D{}
}
return layout.UniformInset(unit.Dp(50)).Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
sz := gtx.Px(unit.Dp(32))
gtx.Constraints.Min = image.Pt(sz, sz)
gtx.Constraints.Max = gtx.Constraints.Min
return material.Loader(ui.theme).Layout(gtx)
})
})
}),
layout.Rigid(func(gtx C) D {
if d.error == nil {
return D{}
}
sz := gtx.Px(unit.Dp(50))
gtx.Constraints.Min.Y = sz
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx C) D {
return layout.W.Layout(gtx, func(gtx C) D {
return material.Body2(ui.theme, d.error.Error()).Layout(gtx)
})
})
}),
layout.Flexed(1, func(gtx C) D {
gtx.Constraints.Min.Y = 0
return d.list.Layout(gtx, len(d.targets), func(gtx C, idx int) D {
node := &d.targets[idx]
target := node.target.Node
lbl := target.ComputedName
offline := target.Online != nil && !*target.Online
if offline {
lbl = lbl + " (offline)"
}
w := material.Body2(ui.theme, lbl)
if offline {
w.Color = rgb(0xbbbbbb)
gtx.Queue = nil
}
return material.Clickable(gtx, &node.btn, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, w.Layout),
layout.Rigid(func(gtx C) D {
sz := gtx.Px(unit.Dp(16))
gtx.Constraints.Min = image.Pt(sz, sz)
switch node.info.State {
case FileSendConnecting:
return material.Loader(ui.theme).Layout(gtx)
case FileSendTransferring:
return material.ProgressCircle(ui.theme, float32(node.info.Progress)).Layout(gtx)
case FileSendFailed:
return ui.icons.error.Layout(gtx, rgb(0xcc6539))
case FileSendComplete:
return ui.icons.done.Layout(gtx, ui.theme.Palette.ContrastBg)
default:
return D{}
}
}),
)
})
})
})
}),
)
})
})
})
}
// layoutExitNodeDialog lays out the exit node selection dialog.
func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []Peer) {
d := &ui.exitDialog
if d.dismiss.Dismissed(gtx) {
d.show = false
}
if !d.show {
return
}
d.dismiss.Add(gtx, argb(0x66000000))
layout.Inset{
Top: unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)),
Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)),
Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)),
Left: unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)),
}.Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Px(unit.Dp(250))
gtx.Constraints.Max.X = gtx.Constraints.Min.X
return layoutDialog(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
// Header.
return layout.Inset{
Top: unit.Dp(16),
Right: unit.Dp(20),
Left: unit.Dp(20),
Bottom: unit.Dp(16),
}.Layout(gtx, func(gtx C) D {
l := material.Body1(ui.theme, "Use exit node...")
l.Font.Weight = text.Bold
return l.Layout(gtx)
})
}),
layout.Flexed(1, func(gtx C) D {
gtx.Constraints.Min.Y = 0
// Add "none" exit node, then "Allow LAN" checkbox, then the exit nodes.
n := len(exits) + 2
return d.list.Layout(gtx, n, func(gtx C, idx int) D {
if idx == 0 {
btn := material.CheckBox(ui.theme, &ui.exitLAN, "Allow LAN access")
return layout.Inset{
Right: unit.Dp(16),
Left: unit.Dp(16),
Bottom: unit.Dp(16),
}.Layout(gtx, btn.Layout)
}
node := Peer{Label: "None", Online: true}
if idx >= 2 {
node = exits[idx-2]
}
lbl := node.Label
if !node.Online {
lbl = lbl + " (offline)"
}
btn := material.RadioButton(ui.theme, &d.exits, string(node.ID), lbl)
if !node.Online {
btn.Color = rgb(0xbbbbbb)
btn.IconColor = btn.Color
}
return layout.Inset{
Right: unit.Dp(16),
Left: unit.Dp(16),
Bottom: unit.Dp(16),
}.Layout(gtx, btn.Layout)
})
}),
)
})
})
})
}
func layoutMenu(th *material.Theme, gtx layout.Context, items []menuItem, header layout.Widget) layout.Dimensions {
return layoutDialog(gtx, func(gtx C) D {
// Lay out menu items twice; once for
// measuring the widest item, once for actual layout.
var maxWidth int
var minWidth int
children := []layout.FlexChild{
layout.Rigid(func(gtx C) D {
return layout.Inset{
Top: unit.Dp(16),
Right: unit.Dp(16),
Left: unit.Dp(16),
Bottom: unit.Dp(4),
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = minWidth
dims := header(gtx)
if w := dims.Size.X; w > maxWidth {
maxWidth = w
}
return dims
})
}),
}
for i := 0; i < len(items); i++ {
it := &items[i]
children = append(children, layout.Rigid(func(gtx C) D {
return material.Clickable(gtx, it.btn, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = minWidth
dims := material.Body1(th, it.title).Layout(gtx)
if w := dims.Size.X; w > maxWidth {
maxWidth = w
}
return dims
})
})
}))
}
f := layout.Flex{Axis: layout.Vertical}
// First pass: record and discard operations
// and determine widest item.
m := op.Record(gtx.Ops)
f.Layout(gtx, children...)
m.Stop()
// Second pass: layout items with equal width.
minWidth = maxWidth
return f.Layout(gtx, children...)
})
}
func layoutDialog(gtx layout.Context, w layout.Widget) layout.Dimensions {
return widget.Border{Color: argb(0x33000000), CornerRadius: unit.Dp(2), Width: unit.Px(1)}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0xfafafa), CornerRadius: unit.Dp(2)}.Layout(gtx, w)
})
}
// layoutMenu lays out the menu activated by the 3 dots button.
func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.Time, showExits bool, needsLogin bool) {
ui.menu.dismiss.Add(gtx, color.NRGBA{})
if ui.menu.dismiss.Dismissed(gtx) {
ui.menu.show = false
}
layout.Inset{
Top: unit.Add(gtx.Metric, sysIns.Top, unit.Dp(2)),
Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(2)),
}.Layout(gtx, func(gtx C) D {
return layout.NE.Layout(gtx, func(gtx C) D {
menu := &ui.menu
if ui.setLoginServer {
return D{}
}
if needsLogin {
items := []menuItem{
{title: "Use login server", btn: &menu.useLoginServer},
}
return layoutMenu(ui.theme, gtx, items, func(gtx C) D {
l := material.Caption(ui.theme, "Advanced settings")
return l.Layout(gtx)
})
}
items := []menuItem{
{title: "Copy my IP address", btn: &menu.copy},
}
if showExits {
items = append(items, menuItem{title: "Use exit node...", btn: &menu.exits})
}
items = append(items,
menuItem{title: "Bug report", btn: &menu.bug},
menuItem{title: "Reauthenticate", btn: &menu.reauth},
menuItem{title: "Log out", btn: &menu.logout},
)
var title string
if ui.runningExit {
title = "Stop running exit node"
} else {
title = "Run exit node"
}
items = append(items, menuItem{title: title, btn: &menu.beExit})
return layoutMenu(ui.theme, gtx, items, func(gtx C) D {
var expiryStr string
const fmtStr = time.Stamp
switch {
case expiry.IsZero():
expiryStr = "Expires: (never)"
case time.Now().After(expiry):
expiryStr = fmt.Sprintf("Expired: %s", expiry.Format(fmtStr))
default:
expiryStr = fmt.Sprintf("Expires: %s", expiry.Format(fmtStr))
}
l := material.Caption(ui.theme, expiryStr)
l.Color = rgb(0x8f8f8f)
return l.Layout(gtx)
})
})
})
}
func (ui *UI) layoutMessage(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
s := ui.message.text
if s == "" {
return D{}
}
now := gtx.Now
d := now.Sub(ui.message.t0)
rem := 4*time.Second - d
if rem < 0 {
return D{}
}
op.InvalidateOp{At: now.Add(rem)}.Add(gtx.Ops)
return layout.S.Layout(gtx, func(gtx C) D {
return layout.Inset{
Left: unit.Add(gtx.Metric, sysIns.Left, unit.Dp(8)),
Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(8)),
Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(8)),
}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0x323232), CornerRadius: unit.Dp(5)}.Layout(gtx, func(gtx C) D {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D {
l := material.Body2(ui.theme, s)
l.Color = rgb(0xdddddd)
return l.Layout(gtx)
})
})
})
})
}
func (ui *UI) showMessage(gtx layout.Context, msg string) {
ui.message.text = msg
ui.message.t0 = gtx.Now
op.InvalidateOp{}.Add(gtx.Ops)
}
// layoutPeer lays out a peer name and IP address (e.g.
// "localhost\n100.100.100.101")
func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *UIPeer, user tailcfg.UserID, clk *widget.Clickable) layout.Dimensions {
return material.Clickable(gtx, clk, func(gtx C) D {
return layout.Inset{
Top: unit.Dp(8),
Right: unit.Max(gtx.Metric, sysIns.Right, unit.Dp(16)),
Left: unit.Max(gtx.Metric, sysIns.Left, unit.Dp(16)),
Bottom: unit.Dp(8),
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
name := p.Peer.DisplayName(p.Peer.User == user)
return material.H6(ui.theme, name).Layout(gtx)
})
}),
layout.Rigid(func(gtx C) D {
var bestIP netaddr.IP // IP to show; first IPv4, or first IPv6 if no IPv4
for _, addr := range p.Peer.Addresses {
if ip := addr.IP(); bestIP.IsZero() || bestIP.Is6() && ip.Is4() {
bestIP = ip
}
}
l := material.Body2(ui.theme, bestIP.String())
l.Color = rgb(0x434343)
return l.Layout(gtx)
}),
)
})
})
}
// layoutSection lays out a section title (e.g. "My devices").
func (ui *UI) layoutSection(gtx layout.Context, sysIns system.Insets, title string) layout.Dimensions {
return Background{Color: rgb(0xe1e0e9)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Top: unit.Dp(16),
Right: unit.Max(gtx.Metric, sysIns.Right, unit.Dp(16)),
Left: unit.Max(gtx.Metric, sysIns.Left, unit.Dp(16)),
Bottom: unit.Dp(16),
}.Layout(gtx, func(gtx C) D {
l := material.Body1(ui.theme, title)
l.Color = rgb(0x6f797d)
return l.Layout(gtx)
})
})
}
// layoutTop lays out the top controls: toggle, status and menu dots.
func (ui *UI) layoutTop(gtx layout.Context, sysIns system.Insets, state *BackendState) layout.Dimensions {
in := layout.Inset{
Top: unit.Dp(16),
Bottom: unit.Dp(16),
}
return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Top: sysIns.Top,
Right: unit.Max(gtx.Metric, sysIns.Right, unit.Dp(8)),
Left: unit.Max(gtx.Metric, sysIns.Left, unit.Dp(16)),
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return in.Layout(gtx, func(gtx C) D {
if state.State <= ipn.NeedsLogin {
return D{}
}
sw := material.Switch(ui.theme, &ui.enabled, "Enable VPN")
sw.Color.Enabled = rgb(white)
if state.State < ipn.Stopped {
sw.Color.Enabled = rgb(0xbbbbbb)
sw.Color.Disabled = rgb(0xbbbbbb)
}
return sw.Layout(gtx)
})
}),
layout.Flexed(1, func(gtx C) D {
return in.Layout(gtx, func(gtx C) D {
return layout.Inset{Left: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
lbl := material.Body1(ui.theme, statusString(state.State))
lbl.Color = rgb(0xffffff)
return lbl.Layout(gtx)
})
})
}),
layout.Rigid(func(gtx C) D {
btn := material.IconButton(ui.theme, &ui.menu.open, ui.icons.more, "Open menu")
btn.Color = rgb(white)
btn.Background = color.NRGBA{}
return btn.Layout(gtx)
}),
)
})
})
}
func statusString(state ipn.State) string {
switch state {
case ipn.Stopped:
return "Stopped"
case ipn.Starting:
return "Starting..."
case ipn.Running:
return "Active"
case ipn.NeedsMachineAuth:
return "Awaiting Approval"
case ipn.NeedsLogin:
return "Tailscale"
default:
return "Loading..."
}
}
func (ui *UI) showCopied(gtx layout.Context, addr string) {
ui.showMessage(gtx, fmt.Sprintf("Copied %s", addr))
}
// layoutLocal lays out the information box about the local node's
// name and IP address.
func (ui *UI) layoutLocal(gtx layout.Context, sysIns system.Insets, host, addr string) layout.Dimensions {
return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Right: unit.Max(gtx.Metric, sysIns.Right, unit.Dp(8)),
Left: unit.Max(gtx.Metric, sysIns.Left, unit.Dp(8)),
Bottom: unit.Dp(8),
}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(infoColor), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
return material.Clickable(gtx, &ui.self, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
name := material.H6(ui.theme, host)
name.Color = rgb(0xffffff)
return name.Layout(gtx)
})
}),
layout.Rigid(func(gtx C) D {
name := material.Body2(ui.theme, addr)
name.Color = rgb(0xc5ccd9)
return name.Layout(gtx)
}),
)
})
})
})
})
})
}
func (ui *UI) layoutSearchbar(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
return Background{Color: rgb(0xf0eff6)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Top: unit.Dp(8),
Right: unit.Max(gtx.Metric, sysIns.Right, unit.Dp(8)),
Left: unit.Max(gtx.Metric, sysIns.Left, unit.Dp(8)),
Bottom: unit.Dp(8),
}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0xe3e2ea), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
col := mulAlpha(ui.theme.Palette.Fg, 0xbb)
return ui.icons.search.Layout(gtx, col)
}),
layout.Flexed(1,
material.Editor(ui.theme, &ui.search, "Search by machine name...").Layout,
),
)
})
})
})
})
}
// drawLogo draws the Tailscale logo using vector operations.
func drawLogo(ops *op.Ops, size int) {
scale := float32(size) / 680
discDia := 170 * scale
off := 172 * 1.5 * scale
tx := op.Offset(f32.Pt(off, 0))
ty := op.Offset(f32.Pt(0, off))
defer op.Offset(f32.Point{}).Push(ops).Pop()
// First row of discs.
row := op.Offset(f32.Point{}).Push(ops)
drawDisc(ops, discDia, rgb(0x54514d))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0x54514d))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0x54514d))
row.Pop()
ty.Add(ops)
// Second row.
row = op.Offset(f32.Point{}).Push(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
row.Pop()
ty.Add(ops)
// Third row.
row = op.Offset(f32.Point{}).Push(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
drawDisc(ops, discDia, rgb(0x54514d))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0x54514d))
row.Pop()
}
func drawImage(gtx layout.Context, img paint.ImageOp, size unit.Value) layout.Dimensions {
img.Add(gtx.Ops)
sz := img.Size()
aspect := float32(sz.Y) / float32(sz.X)
w := gtx.Px(size)
h := int(float32(w)*aspect + .5)
scale := float32(w) / float32(sz.X)
defer op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Point{X: scale, Y: scale})).Push(gtx.Ops).Pop()
paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: image.Pt(w, h)}
}
func drawDisc(ops *op.Ops, radius float32, col color.NRGBA) {
r2 := radius * .5
dr := f32.Rectangle{Max: f32.Pt(radius, radius)}
defer clip.RRect{
Rect: dr,
NE: r2, NW: r2, SE: r2, SW: r2,
}.Push(ops).Pop()
paint.ColorOp{Color: col}.Add(ops)
paint.PaintOp{}.Add(ops)
}
// Background lays out a widget and draws a color background behind it.
type Background struct {
Color color.NRGBA
CornerRadius unit.Value
}
func (b Background) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
m := op.Record(gtx.Ops)
dims := w(gtx)
sz := dims.Size
call := m.Stop()
// Clip corners, if any.
if r := gtx.Px(b.CornerRadius); r > 0 {
rr := float32(r)
defer clip.RRect{
Rect: f32.Rectangle{Max: f32.Point{
X: float32(sz.X),
Y: float32(sz.Y),
}},
NE: rr, NW: rr, SE: rr, SW: rr,
}.Push(gtx.Ops).Pop()
}
fill{b.Color}.Layout(gtx, sz)
call.Add(gtx.Ops)
return dims
}
type fill struct {
col color.NRGBA
}
func (f fill) Layout(gtx layout.Context, sz image.Point) layout.Dimensions {
defer clip.Rect(image.Rectangle{Max: sz}).Push(gtx.Ops).Pop()
paint.ColorOp{Color: f.col}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: sz}
}
func rgb(c uint32) color.NRGBA {
return argb((0xff << 24) | c)
}
func argb(c uint32) color.NRGBA {
return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}
const termsText = `Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.
We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.`
================================================
FILE: flake.nix
================================================
{
description = "Tailscale build environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
android.url = "github:tadfisher/android-nixpkgs";
android.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, android }:
let
supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
in
{
devShells = forAllSystems
(system:
let
pkgs = import nixpkgs {
inherit system;
};
android-sdk = android.sdk.${system} (sdkPkgs: with sdkPkgs;
[
build-tools-30-0-2
cmdline-tools-latest
platform-tools
platforms-android-31
platforms-android-30
ndk-23-1-7779620
patcher-v4
]);
in
{
default = (with pkgs; buildFHSUserEnv {
name = "tailscale";
profile = ''
export ANDROID_SDK_ROOT="${android-sdk}/share/android-sdk"
export JAVA_HOME="${jdk8.home}"
'';
targetPkgs = pkgs: with pkgs; [
android-sdk
jdk8
clang
] ++ (if stdenv.isLinux then [
vulkan-headers
libxkbcommon
wayland
xorg.libX11
xorg.libXcursor
xorg.libXfixes
libGL
pkgconfig
] else [ ]);
}).env;
}
);
};
}
================================================
FILE: go.mod
================================================
module github.com/tailscale/tailscale-android
go 1.18
require (
eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3
gioui.org v0.0.0-20220331105829-a1b5ff059c07
gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/exp v0.0.0-20210722180016-6781d3edade3
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d
golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478
inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.1.1-0.20220718172352-3c892d106c2e
)
require (
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
gioui.org/shader v1.0.6 // indirect
github.com/akavel/rsrc v0.10.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/benoitkugler/textlayout v0.0.10 // indirect
github.com/coreos/go-iptables v0.6.0 // indirect
github.com/creack/pty v1.1.17 // indirect
github.com/gioui/uax v0.2.1-0.20220325163150-e3d987515a12 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-text/typesetting v0.0.0-20220112121102-58fe93c84506 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect
github.com/klauspost/compress v1.15.4 // indirect
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
github.com/mdlayher/genetlink v1.2.0 // indirect
github.com/mdlayher/netlink v1.6.0 // indirect
github.com/mdlayher/sdnotify v1.0.0 // indirect
github.com/mdlayher/socket v0.2.3 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect
github.com/tailscale/golang-x-crypto v0.0.0-20220428210705-0b941c09a5e1 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect
github.com/tcnksm/go-httpstat v0.2.0 // indirect
github.com/u-root/u-root v0.8.0 // indirect
github.com/u-root/uio v0.0.0-20210528151154-e40b768296a7 // indirect
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 // indirect
github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect
go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
golang.org/x/tools v0.1.11 // indirect
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
golang.zx2c4.com/wireguard/windows v0.4.10 // indirect
gvisor.dev/gvisor v0.0.0-20220407223209-21871174d445 // indirect
nhooyr.io/websocket v1.8.7 // indirect
)
================================================
FILE: go.sum
================================================
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3 h1:djFprmHZgrSepsHAIRMp5UJn3PzsoTg9drI+BDmif5Q=
eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
filippo.io/mkcert v1.4.3 h1:axpnmtrZMM8u5Hf4N3UXxboGemMOV+Tn+e+pkHM6E3o=
gioui.org v0.0.0-20210910062418-d5d0a75a9bcb/go.mod h1:BTldRXnY5mrUrYZCdWyDwyMzyUzpfZN1cF4MMRrOt9w=
gioui.org v0.0.0-20220331105829-a1b5ff059c07 h1:9BdOhoKqRiItOl87wZfQUL2YZNm+6Yyp1f1iEiJUh+U=
gioui.org v0.0.0-20220331105829-a1b5ff059c07/go.mod h1:b8vBukexG6eYuXZa14asjLAWJ+JjbZ/ophEnS2FjYUg=
gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6 h1:SkAdohDhTUjl+ZtM417Xeu+uFd7SQubwR9uAyqJqC8c=
gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6/go.mod h1:qrH3h4nt/PyIqx/XabL/eJ5cXQnQ0ERHqC3VEXx/Rmg=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.2/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/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/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.0.5/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/benoitkugler/textlayout v0.0.10 h1:uIaQgH4pBFw1LQ0tPkfjgxo94WYcckzzQaB41L2X84w=
github.com/benoitkugler/textlayout v0.0.10/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/cilium/ebpf v0.5.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
github.com/cilium/ebpf v0.7.0 h1:1k/q3ATgxSXRdrmPfH8d7YK0GfqVsEKZAX9dQZvs56k=
github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
github.com/cilium/ebpf v0.8.1 h1:bLSSEbBLqGPXxls55pGr5qWZaTqcmfDJHhou7t254ao=
github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk=
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnV
gitextract_xph_jeqj/
├── .github/
│ └── workflows/
│ └── main.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── PATENTS
├── README.md
├── android/
│ ├── build.gradle
│ ├── gradle/
│ │ └── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── tailscale/
│ │ │ └── ipn/
│ │ │ ├── App.java
│ │ │ ├── DnsConfig.java
│ │ │ ├── IPNActivity.java
│ │ │ ├── IPNService.java
│ │ │ ├── Peer.java
│ │ │ └── QuickToggleService.java
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ └── ic_tile.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── values/
│ │ ├── ic_launcher_background.xml
│ │ └── strings.xml
│ ├── play/
│ │ └── java/
│ │ └── com/
│ │ └── tailscale/
│ │ └── ipn/
│ │ └── Google.java
│ └── test/
│ └── java/
│ └── com/
│ └── tailscale/
│ └── ipn/
│ └── DnsConfigTest.java
├── cmd/
│ └── tailscale/
│ ├── backend.go
│ ├── callbacks.go
│ ├── main.go
│ ├── multitun.go
│ ├── pprof.go
│ ├── store.go
│ ├── tools.go
│ └── ui.go
├── flake.nix
├── go.mod
├── go.sum
├── jni/
│ └── jni.go
├── metadata/
│ └── en-US/
│ ├── full_description.txt
│ └── short_description.txt
└── version/
└── tailscale-version.sh
SYMBOL INDEX (322 symbols across 16 files)
FILE: android/src/main/java/com/tailscale/ipn/App.java
class App (line 73) | public class App extends Application {
method getDnsConfigObj (line 88) | public DnsConfig getDnsConfigObj() { return this.dns; }
method onCreate (line 90) | @Override public void onCreate() {
method registerNetworkCallback (line 102) | private void registerNetworkCallback() {
method startVPN (line 126) | public void startVPN() {
method stopVPN (line 132) | public void stopVPN() {
method encryptToPref (line 140) | public void encryptToPref(String prefKey, String plaintext) throws IOE...
method decryptFromPref (line 146) | public String decryptFromPref(String prefKey) throws IOException, Gene...
method getEncryptedPrefs (line 150) | private SharedPreferences getEncryptedPrefs() throws IOException, Gene...
method setTileReady (line 164) | void setTileReady(boolean ready) {
method setTileStatus (line 171) | void setTileStatus(boolean status) {
method getHostname (line 178) | String getHostname() {
method getModelName (line 185) | String getModelName() {
method getOSVersion (line 197) | String getOSVersion() {
method getUserConfiguredDeviceName (line 203) | private String getUserConfiguredDeviceName() {
method isEmpty (line 214) | private static boolean isEmpty(String str) {
method attachPeer (line 220) | void attachPeer(Activity act) {
method isChromeOS (line 231) | boolean isChromeOS() {
method prepareVPN (line 235) | void prepareVPN(Activity act, int reqCode) {
method startActivityForResult (line 248) | static void startActivityForResult(Activity act, Intent intent, int re...
method showURL (line 253) | void showURL(Activity act, String url) {
method getPackageCertificate (line 266) | byte[] getPackageCertificate() throws Exception {
method requestWriteStoragePermission (line 275) | void requestWriteStoragePermission(Activity act) {
method insertMedia (line 286) | String insertMedia(String name, String mimeType) throws IOException {
method openUri (line 304) | int openUri(String uri, String mode) throws IOException {
method deleteUri (line 309) | void deleteUri(String uri) {
method notifyFile (line 314) | public void notifyFile(String uri, String msg) {
method createNotificationChannel (line 336) | private void createNotificationChannel(String id, String name, int imp...
method onVPNPrepared (line 345) | static native void onVPNPrepared();
method onConnectivityChanged (line 346) | private static native void onConnectivityChanged(boolean connected);
method onShareIntent (line 347) | static native void onShareIntent(int nfiles, int[] types, String[] mim...
method onWriteStorageGranted (line 348) | static native void onWriteStorageGranted();
method getInterfacesAsString (line 366) | String getInterfacesAsString() {
method isTV (line 400) | boolean isTV() {
FILE: android/src/main/java/com/tailscale/ipn/DnsConfig.java
class DnsConfig (line 88) | public class DnsConfig {
method DnsConfig (line 91) | public DnsConfig(Context ctx) {
method getDnsConfigAsString (line 104) | String getDnsConfigAsString() {
method getDnsConfigFromLinkProperties (line 142) | String getDnsConfigFromLinkProperties() {
method getDnsServersFromSystemProperties (line 212) | String getDnsServersFromSystemProperties() {
method intToInetString (line 232) | public String intToInetString(int hostAddress) {
method getDnsServersFromNetworkInfo (line 251) | String getDnsServersFromNetworkInfo() {
method getPreferabilityForNetwork (line 340) | int getPreferabilityForNetwork(ConnectivityManager cMgr, Network netwo...
FILE: android/src/main/java/com/tailscale/ipn/IPNActivity.java
class IPNActivity (line 22) | public final class IPNActivity extends Activity {
method onCreate (line 27) | @Override public void onCreate(Bundle state) {
method onNewIntent (line 34) | @Override public void onNewIntent(Intent i) {
method handleIntent (line 39) | private void handleIntent() {
method onRequestPermissionsResult (line 95) | @Override public void onRequestPermissionsResult(int reqCode, String[]...
method onDestroy (line 104) | @Override public void onDestroy() {
method onStart (line 109) | @Override public void onStart() {
method onStop (line 114) | @Override public void onStop() {
method onConfigurationChanged (line 119) | @Override public void onConfigurationChanged(Configuration c) {
method onLowMemory (line 124) | @Override public void onLowMemory() {
method onBackPressed (line 129) | @Override public void onBackPressed() {
FILE: android/src/main/java/com/tailscale/ipn/IPNService.java
class IPNService (line 19) | public class IPNService extends VpnService {
method onStartCommand (line 23) | @Override public int onStartCommand(Intent intent, int flags, int star...
method close (line 32) | private void close() {
method onDestroy (line 37) | @Override public void onDestroy() {
method onRevoke (line 42) | @Override public void onRevoke() {
method configIntent (line 47) | private PendingIntent configIntent() {
method disallowApp (line 51) | private void disallowApp(VpnService.Builder b, String name) {
method newBuilder (line 59) | protected VpnService.Builder newBuilder() {
method notify (line 81) | public void notify(String title, String message) {
method updateStatusNotification (line 95) | public void updateStatusNotification(String title, String message) {
method connect (line 106) | private native void connect();
method disconnect (line 107) | private native void disconnect();
FILE: android/src/main/java/com/tailscale/ipn/Peer.java
class Peer (line 11) | public class Peer extends Fragment {
method onActivityResult (line 12) | @Override public void onActivityResult(int requestCode, int resultCode...
method onActivityResult0 (line 16) | private static native void onActivityResult0(Activity act, int reqCode...
FILE: android/src/main/java/com/tailscale/ipn/QuickToggleService.java
class QuickToggleService (line 16) | public class QuickToggleService extends TileService {
method onStartListening (line 27) | @Override public void onStartListening() {
method onStopListening (line 34) | @Override public void onStopListening() {
method onClick (line 40) | @Override public void onClick() {
method updateTile (line 54) | private static void updateTile() {
method setReady (line 68) | static void setReady(Context ctx, boolean rdy) {
method setStatus (line 75) | static void setStatus(Context ctx, boolean act) {
method onTileClick (line 82) | private static native void onTileClick();
FILE: android/src/play/java/com/tailscale/ipn/Google.java
class Google (line 17) | public final class Google {
method getIdTokenForActivity (line 18) | static String getIdTokenForActivity(Activity act) {
method googleSignIn (line 23) | static void googleSignIn(Activity act, String serverOAuthID, int reqCo...
method googleSignOut (line 37) | static void googleSignOut(Context ctx) {
FILE: android/src/test/java/com/tailscale/ipn/DnsConfigTest.java
class DnsConfigTest (line 7) | public class DnsConfigTest {
method setup (line 10) | @Before
method dnsConfig_intToInetStringTest (line 15) | @Test
FILE: cmd/tailscale/backend.go
type backend (line 36) | type backend struct
method Start (line 149) | func (b *backend) Start(notify func(n ipn.Notify)) error {
method LinkChange (line 156) | func (b *backend) LinkChange() {
method setCfg (line 162) | func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error {
method updateTUN (line 166) | func (b *backend) updateTUN(service jni.Object, rcfg *router.Config, d...
method CloseTUNs (line 302) | func (b *backend) CloseTUNs() {
method SetupLogs (line 308) | func (b *backend) SetupLogs(logDir string, logID logtail.PrivateID) {
method logDNSConfigMechanisms (line 364) | func (b *backend) logDNSConfigMechanisms() {
method getPlatformDNSConfig (line 395) | func (b *backend) getPlatformDNSConfig() string {
method getDNSBaseConfig (line 417) | func (b *backend) getDNSBaseConfig() (dns.OSConfig, error) {
type settingsFunc (line 54) | type settingsFunc
constant defaultMTU (line 56) | defaultMTU = 1280
constant logPrefKey (line 59) | logPrefKey = "privatelogid"
constant loginMethodPrefKey (line 60) | loginMethodPrefKey = "loginmethod"
constant customLoginServerPrefKey (line 61) | customLoginServerPrefKey = "customloginserver"
constant loginMethodGoogle (line 65) | loginMethodGoogle = "google"
constant loginMethodWeb (line 66) | loginMethodWeb = "web"
function newBackend (line 81) | func newBackend(dataDir string, jvm *jni.JVM, appCtx jni.Object, store *...
FILE: cmd/tailscale/callbacks.go
constant requestSignin (line 50) | requestSignin C.jint = 1000 + iota
constant requestPrepareVPN (line 53) | requestPrepareVPN
constant resultOK (line 57) | resultOK = -1
function Java_com_tailscale_ipn_App_onVPNPrepared (line 60) | func Java_com_tailscale_ipn_App_onVPNPrepared(env *C.JNIEnv, class C.jcl...
function Java_com_tailscale_ipn_App_onWriteStorageGranted (line 65) | func Java_com_tailscale_ipn_App_onWriteStorageGranted(env *C.JNIEnv, cla...
function notifyVPNPrepared (line 72) | func notifyVPNPrepared() {
function notifyVPNRevoked (line 79) | func notifyVPNRevoked() {
function notifyVPNClosed (line 86) | func notifyVPNClosed() {
function Java_com_tailscale_ipn_IPNService_connect (line 94) | func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.job...
function Java_com_tailscale_ipn_IPNService_disconnect (line 100) | func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C....
function Java_com_tailscale_ipn_App_onConnectivityChanged (line 106) | func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls...
function Java_com_tailscale_ipn_QuickToggleService_onTileClick (line 115) | func Java_com_tailscale_ipn_QuickToggleService_onTileClick(env *C.JNIEnv...
function Java_com_tailscale_ipn_Peer_onActivityResult0 (line 120) | func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C....
function Java_com_tailscale_ipn_App_onShareIntent (line 148) | func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclas...
FILE: cmd/tailscale/main.go
type App (line 48) | type App struct
method runBackend (line 248) | func (a *App) runBackend() error {
method processWaitingFiles (line 524) | func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error {
method downloadFile (line 538) | func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.Waiting...
method openURI (line 591) | func (a *App) openURI(uri, mode string) (*os.File, error) {
method isChromeOS (line 608) | func (a *App) isChromeOS() bool {
method hostname (line 675) | func (a *App) hostname() string {
method osVersion (line 692) | func (a *App) osVersion() string {
method modelName (line 709) | func (a *App) modelName() string {
method updateNotification (line 729) | func (a *App) updateNotification(service jni.Object, state ipn.State) ...
method notifyExpiry (line 751) | func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time...
method notifyFile (line 781) | func (a *App) notifyFile(uri, msg string) error {
method pushNotify (line 791) | func (a *App) pushNotify(service jni.Object, title, msg string) error {
method notify (line 801) | func (a *App) notify(state BackendState) {
method setPrefs (line 813) | func (a *App) setPrefs(prefs *ipn.Prefs) {
method setURL (line 825) | func (a *App) setURL(url string) {
method runUI (line 833) | func (a *App) runUI() error {
method isTV (line 966) | func (a *App) isTV() bool {
method isReleaseSigned (line 983) | func (a *App) isReleaseSigned() bool {
method attachPeer (line 1009) | func (a *App) attachPeer(act jni.Object) {
method updateState (line 1016) | func (a *App) updateState(act jni.Object, state *clientState) {
method prepareVPN (line 1086) | func (a *App) prepareVPN(act jni.Object) error {
method processUIEvents (line 1097) | func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni...
method sendFiles (line 1144) | func (a *App) sendFiles(e FileSendEvent, files []File) {
method invalidate (line 1180) | func (a *App) invalidate() {
method sendFile (line 1187) | func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget...
method signOut (line 1243) | func (a *App) signOut() {
method googleSignIn (line 1257) | func (a *App) googleSignIn(act jni.Object) {
method browseToURL (line 1273) | func (a *App) browseToURL(act jni.Object, url string) {
method callVoidMethod (line 1286) | func (a *App) callVoidMethod(obj jni.Object, name, sig string, args .....
method contextForView (line 1299) | func (a *App) contextForView(view jni.Object) jni.Object {
method getInterfaces (line 1319) | func (a *App) getInterfaces() ([]interfaces.Interface, error) {
type FileTargets (line 73) | type FileTargets struct
type File (line 78) | type File struct
type FileSendInfo (line 90) | type FileSendInfo struct
type clientState (line 97) | type clientState struct
type FileType (line 106) | type FileType
constant FileTypeText (line 110) | FileTypeText FileType = 1
constant FileTypeURI (line 111) | FileTypeURI FileType = 2
type ExitStatus (line 114) | type ExitStatus
constant ExitNone (line 118) | ExitNone ExitStatus = iota
constant ExitOffline (line 120) | ExitOffline
constant ExitOnline (line 122) | ExitOnline
type FileSendState (line 125) | type FileSendState
constant FileSendNotStarted (line 128) | FileSendNotStarted FileSendState = iota
constant FileSendConnecting (line 129) | FileSendConnecting
constant FileSendTransferring (line 130) | FileSendTransferring
constant FileSendComplete (line 131) | FileSendComplete
constant FileSendFailed (line 132) | FileSendFailed
type Peer (line 135) | type Peer struct
type BackendState (line 141) | type BackendState struct
method updateExitNodes (line 623) | func (s *BackendState) updateExitNodes() {
type UIEvent (line 155) | type UIEvent interface
type RouteAllEvent (line 157) | type RouteAllEvent struct
type ConnectEvent (line 161) | type ConnectEvent struct
type CopyEvent (line 165) | type CopyEvent struct
type SearchEvent (line 169) | type SearchEvent struct
type OAuth2Event (line 173) | type OAuth2Event struct
type FileSendEvent (line 177) | type FileSendEvent struct
type SetLoginServerEvent (line 183) | type SetLoginServerEvent struct
type ToggleEvent (line 189) | type ToggleEvent struct
type ReauthEvent (line 190) | type ReauthEvent struct
type BugEvent (line 191) | type BugEvent struct
type WebAuthEvent (line 192) | type WebAuthEvent struct
type GoogleAuthEvent (line 193) | type GoogleAuthEvent struct
type LogoutEvent (line 194) | type LogoutEvent struct
type BeExitNodeEvent (line 195) | type BeExitNodeEvent
type ExitAllowLANEvent (line 196) | type ExitAllowLANEvent
constant serverOAuthID (line 201) | serverOAuthID = "744055068597-hv4opg0h7vskq1hv37nq3u26t8c15qk0.apps.goog...
constant releaseCertFingerprint (line 205) | releaseCertFingerprint = "86:9D:11:8B:63:1E:F8:35:C6:D9:C2:66:53:BC:28:2...
function main (line 210) | func main() {
function googleSignInEnabled (line 724) | func googleSignInEnabled() bool {
function requestBackend (line 1091) | func requestBackend(e UIEvent) {
type progressReader (line 1222) | type progressReader struct
method Read (line 1230) | func (r *progressReader) Read(p []byte) (int, error) {
function fatalErr (line 1398) | func fatalErr(err error) {
function randHex (line 1403) | func randHex(n int) string {
FILE: cmd/tailscale/multitun.go
type multiTUN (line 17) | type multiTUN struct
method run (line 84) | func (d *multiTUN) run() {
method readFrom (line 172) | func (d *multiTUN) readFrom(dev *tunDevice) {
method runDevice (line 199) | func (d *multiTUN) runDevice(dev *tunDevice) {
method add (line 235) | func (d *multiTUN) add(dev tun.Device) {
method File (line 239) | func (d *multiTUN) File() *os.File {
method Read (line 245) | func (d *multiTUN) Read(data []byte, offset int) (int, error) {
method Write (line 252) | func (d *multiTUN) Write(data []byte, offset int) (int, error) {
method Flush (line 259) | func (d *multiTUN) Flush() error {
method MTU (line 265) | func (d *multiTUN) MTU() (int, error) {
method Name (line 272) | func (d *multiTUN) Name() (string, error) {
method Events (line 279) | func (d *multiTUN) Events() chan tun.Event {
method Shutdown (line 283) | func (d *multiTUN) Shutdown() {
method Close (line 288) | func (d *multiTUN) Close() error {
type tunDevice (line 36) | type tunDevice struct
type ioRequest (line 45) | type ioRequest struct
type ioReply (line 51) | type ioReply struct
type mtuReply (line 56) | type mtuReply struct
type nameReply (line 61) | type nameReply struct
function newTUNDevices (line 66) | func newTUNDevices() *multiTUN {
FILE: cmd/tailscale/pprof.go
function init (line 15) | func init() {
FILE: cmd/tailscale/store.go
type stateStore (line 18) | type stateStore struct
method ReadString (line 52) | func (s *stateStore) ReadString(key string, def string) (string, error) {
method WriteString (line 63) | func (s *stateStore) WriteString(key string, val string) error {
method ReadBool (line 67) | func (s *stateStore) ReadBool(key string, def bool) (bool, error) {
method WriteBool (line 78) | func (s *stateStore) WriteBool(key string, val bool) error {
method ReadState (line 86) | func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) {
method WriteState (line 97) | func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error {
method read (line 102) | func (s *stateStore) read(key string) ([]byte, error) {
method write (line 121) | func (s *stateStore) write(key string, value []byte) error {
function newStateStore (line 28) | func newStateStore(jvm *jni.JVM, appCtx jni.Object) *stateStore {
function prefKeyFor (line 48) | func prefKeyFor(id ipn.StateKey) string {
FILE: cmd/tailscale/ui.go
type UI (line 42) | type UI struct
method onBack (line 258) | func (ui *UI) onBack() bool {
method activeDialog (line 267) | func (ui *UI) activeDialog() *bool {
method layout (line 281) | func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *...
method layoutQR (line 531) | func (ui *UI) layoutQR(gtx layout.Context, sysIns system.Insets) layou...
method FillShareDialog (line 538) | func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) {
method ShowShareDialog (line 559) | func (ui *UI) ShowShareDialog() {
method ShowMessage (line 563) | func (ui *UI) ShowMessage(msg string) {
method ShowQRCode (line 568) | func (ui *UI) ShowQRCode(url string) {
method layoutExitStatus (line 599) | func (ui *UI) layoutExitStatus(gtx layout.Context, state *BackendState...
method layoutSignIn (line 645) | func (ui *UI) layoutSignIn(gtx layout.Context, state *BackendState) la...
method withLoader (line 745) | func (ui *UI) withLoader(gtx layout.Context, loading bool, w layout.Wi...
method layoutDisconnected (line 768) | func (ui *UI) layoutDisconnected(gtx layout.Context) layout.Dimensions {
method layoutIntro (line 790) | func (ui *UI) layoutIntro(gtx layout.Context, sysIns system.Insets) {
method menuClicked (line 844) | func (ui *UI) menuClicked(btn *widget.Clickable) bool {
method layoutShareDialog (line 853) | func (ui *UI) layoutShareDialog(gtx layout.Context, sysIns system.Inse...
method layoutExitNodeDialog (line 962) | func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.I...
method layoutMenu (line 1091) | func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, exp...
method layoutMessage (line 1153) | func (ui *UI) layoutMessage(gtx layout.Context, sysIns system.Insets) ...
method showMessage (line 1182) | func (ui *UI) showMessage(gtx layout.Context, msg string) {
method layoutPeer (line 1190) | func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *...
method layoutSection (line 1223) | func (ui *UI) layoutSection(gtx layout.Context, sysIns system.Insets, ...
method layoutTop (line 1239) | func (ui *UI) layoutTop(gtx layout.Context, sysIns system.Insets, stat...
method showCopied (line 1302) | func (ui *UI) showCopied(gtx layout.Context, addr string) {
method layoutLocal (line 1308) | func (ui *UI) layoutLocal(gtx layout.Context, sysIns system.Insets, ho...
method layoutSearchbar (line 1340) | func (ui *UI) layoutSearchbar(gtx layout.Context, sysIns system.Insets...
type shareTarget (line 136) | type shareTarget struct
type signinType (line 144) | type signinType
type UIPeer (line 148) | type UIPeer struct
type menuItem (line 158) | type menuItem struct
constant headerColor (line 164) | headerColor = 0x496495
constant infoColor (line 165) | infoColor = 0x3a517b
constant white (line 166) | white = 0xffffff
constant keyShowIntro (line 170) | keyShowIntro = "ui.showintro"
constant noSignin (line 174) | noSignin signinType = iota
constant webSignin (line 175) | webSignin
constant googleSignin (line 176) | googleSignin
function newUI (line 191) | func newUI(store *stateStore) (*UI, error) {
function mulAlpha (line 253) | func mulAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
type Dismiss (line 579) | type Dismiss struct
method Add (line 582) | func (d *Dismiss) Add(gtx layout.Context, color color.NRGBA) {
method Dismissed (line 588) | func (d *Dismiss) Dismissed(gtx layout.Context) bool {
function layoutMenu (line 1034) | func layoutMenu(th *material.Theme, gtx layout.Context, items []menuItem...
function layoutDialog (line 1084) | func layoutDialog(gtx layout.Context, w layout.Widget) layout.Dimensions {
function statusString (line 1285) | func statusString(state ipn.State) string {
function drawLogo (line 1366) | func drawLogo(ops *op.Ops, size int) {
function drawImage (line 1406) | func drawImage(gtx layout.Context, img paint.ImageOp, size unit.Value) l...
function drawDisc (line 1418) | func drawDisc(ops *op.Ops, radius float32, col color.NRGBA) {
type Background (line 1430) | type Background struct
method Layout (line 1435) | func (b Background) Layout(gtx layout.Context, w layout.Widget) layout...
type fill (line 1456) | type fill struct
method Layout (line 1460) | func (f fill) Layout(gtx layout.Context, sz image.Point) layout.Dimens...
function rgb (line 1467) | func rgb(c uint32) color.NRGBA {
function argb (line 1471) | func argb(c uint32) color.NRGBA {
constant termsText (line 1475) | termsText = `Tailscale is a mesh VPN for securely connecting your device...
FILE: jni/jni.go
type JVM (line 167) | type JVM
type Env (line 169) | type Env
type Class (line 172) | type Class
type Object (line 173) | type Object
type MethodID (line 174) | type MethodID
type String (line 175) | type String
type ByteArray (line 176) | type ByteArray
type ObjectArray (line 177) | type ObjectArray
type BooleanArray (line 178) | type BooleanArray
type LongArray (line 179) | type LongArray
type IntArray (line 180) | type IntArray
type Boolean (line 181) | type Boolean
type Value (line 182) | type Value
function env (line 193) | func env(e *Env) *C.JNIEnv {
function javavm (line 197) | func javavm(vm *JVM) *C.JavaVM {
function Do (line 203) | func Do(vm *JVM, f func(env *Env) error) error {
function Bool (line 220) | func Bool(b bool) Boolean {
function varArgs (line 227) | func varArgs(args []Value) *C.jvalue {
function IsSameObject (line 234) | func IsSameObject(e *Env, ref1, ref2 Object) bool {
function CallStaticIntMethod (line 239) | func CallStaticIntMethod(e *Env, cls Class, method MethodID, args ...Val...
function CallStaticVoidMethod (line 244) | func CallStaticVoidMethod(e *Env, cls Class, method MethodID, args ...Va...
function CallVoidMethod (line 249) | func CallVoidMethod(e *Env, obj Object, method MethodID, args ...Value) ...
function CallStaticObjectMethod (line 254) | func CallStaticObjectMethod(e *Env, cls Class, method MethodID, args ......
function CallObjectMethod (line 259) | func CallObjectMethod(e *Env, obj Object, method MethodID, args ...Value...
function CallBooleanMethod (line 264) | func CallBooleanMethod(e *Env, obj Object, method MethodID, args ...Valu...
function CallIntMethod (line 269) | func CallIntMethod(e *Env, obj Object, method MethodID, args ...Value) (...
function GetByteArrayElements (line 275) | func GetByteArrayElements(e *Env, jarr ByteArray) []byte {
function GetBooleanArrayElements (line 289) | func GetBooleanArrayElements(e *Env, jarr BooleanArray) []bool {
function GetStringArrayElements (line 305) | func GetStringArrayElements(e *Env, jarr ObjectArray) []string {
function GetIntArrayElements (line 315) | func GetIntArrayElements(e *Env, jarr IntArray) []int {
function GetLongArrayElements (line 331) | func GetLongArrayElements(e *Env, jarr LongArray) []int64 {
function iterateObjectArray (line 346) | func iterateObjectArray(e *Env, jarr ObjectArray, f func(e *Env, idx int...
function NewByteArray (line 360) | func NewByteArray(e *Env, content []byte) ByteArray {
function ClassLoaderFor (line 374) | func ClassLoaderFor(e *Env, obj Object) Object {
function LoadClass (line 387) | func LoadClass(e *Env, loader Object, class string) (Class, error) {
function exception (line 401) | func exception(e *Env) error {
function GetObjectClass (line 417) | func GetObjectClass(e *Env, obj Object) Class {
function GetStaticMethodID (line 431) | func GetStaticMethodID(e *Env, cls Class, name, signature string) Method...
function GetMethodID (line 445) | func GetMethodID(e *Env, cls Class, name, signature string) MethodID {
function NewGlobalRef (line 457) | func NewGlobalRef(e *Env, obj Object) Object {
function DeleteGlobalRef (line 461) | func DeleteGlobalRef(e *Env, obj Object) {
function JavaString (line 466) | func JavaString(e *Env, str string) String {
function GoString (line 476) | func GoString(e *Env, str String) string {
Condensed preview — 43 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (305K chars).
[
{
"path": ".github/workflows/main.yml",
"chars": 1790,
"preview": "# This is a basic workflow to help you get started with Actions\n\nname: CI\n\n# Controls when the workflow will run\non:\n #"
},
{
"path": ".gitignore",
"chars": 218,
"preview": "# Ignore Gradle project-specific cache directory\n.gradle\n\n# Ignore Gradle build output directory\nbuild\n\n# The destinatio"
},
{
"path": "Dockerfile",
"chars": 1892,
"preview": "# This is a Dockerfile for creating a build environment for\n# tailscale-android.\n\nFROM openjdk:8-jdk\n\n# To enable runnin"
},
{
"path": "LICENSE",
"chars": 1487,
"preview": "Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved.\n\nRedistribution and use in source and binary forms, with or"
},
{
"path": "Makefile",
"chars": 4317,
"preview": "# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n# Use of this source code is governed by a BSD-style\n#"
},
{
"path": "PATENTS",
"chars": 1377,
"preview": "Additional IP Rights Grant (Patents)\n\n\"This implementation\" means the copyrightable works distributed by\nTailscale Inc. "
},
{
"path": "README.md",
"chars": 4279,
"preview": "# Tailscale Android Client\n\nhttps://tailscale.com\n\nPrivate WireGuard® networks made easy\n\n## Overview\n\nThis repository c"
},
{
"path": "android/build.gradle",
"chars": 1107,
"preview": "buildscript {\n\trepositories {\n\t\tgoogle()\n\t\tjcenter()\n\t}\n\tdependencies {\n\t\tclasspath 'com.android.tools.build:gradle:4.2."
},
{
"path": "android/gradle/wrapper/gradle-wrapper.properties",
"chars": 289,
"preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionSha256Sum=3239b5ed86c3838a37d983ac100573f64"
},
{
"path": "android/gradle.properties",
"chars": 25,
"preview": "android.useAndroidX=true\n"
},
{
"path": "android/gradlew",
"chars": 5764,
"preview": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0"
},
{
"path": "android/gradlew.bat",
"chars": 3056,
"preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
},
{
"path": "android/src/main/AndroidManifest.xml",
"chars": 3243,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n\tpackage=\"co"
},
{
"path": "android/src/main/java/com/tailscale/ipn/App.java",
"chars": 14774,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/main/java/com/tailscale/ipn/DnsConfig.java",
"chars": 13991,
"preview": "// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/main/java/com/tailscale/ipn/IPNActivity.java",
"chars": 3565,
"preview": "// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/main/java/com/tailscale/ipn/IPNService.java",
"chars": 3397,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/main/java/com/tailscale/ipn/Peer.java",
"chars": 569,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/main/java/com/tailscale/ipn/QuickToggleService.java",
"chars": 1947,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/main/res/drawable/ic_launcher_foreground.xml",
"chars": 1378,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"108dp\"\n android:height=\"108dp\"\n"
},
{
"path": "android/src/main/res/drawable/ic_tile.xml",
"chars": 1320,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
"chars": 267,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <b"
},
{
"path": "android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
"chars": 267,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <b"
},
{
"path": "android/src/main/res/values/ic_launcher_background.xml",
"chars": 120,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"ic_launcher_background\">#1F2125</color>\n</resources>"
},
{
"path": "android/src/main/res/values/strings.xml",
"chars": 159,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"app_name\">Tailscale</string>\n <string name=\"tile"
},
{
"path": "android/src/play/java/com/tailscale/ipn/Google.java",
"chars": 1538,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "android/src/test/java/com/tailscale/ipn/DnsConfigTest.java",
"chars": 476,
"preview": "import org.junit.Before;\nimport org.junit.Test;\n\nimport static org.junit.Assert.assertEquals;\nimport com.tailscale.ipn.D"
},
{
"path": "cmd/tailscale/backend.go",
"chars": 12878,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/callbacks.go",
"chars": 5186,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/main.go",
"chars": 36735,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/multitun.go",
"chars": 6315,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/pprof.go",
"chars": 334,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/store.go",
"chars": 3027,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/tools.go",
"chars": 256,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "cmd/tailscale/ui.go",
"chars": 41106,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "flake.nix",
"chars": 1665,
"preview": "{\n description = \"Tailscale build environment\";\n\n inputs = {\n nixpkgs.url = \"github:NixOS/nixpkgs\";\n android.url"
},
{
"path": "go.mod",
"chars": 3781,
"preview": "module github.com/tailscale/tailscale-android\n\ngo 1.18\n\nrequire (\n\teliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3"
},
{
"path": "go.sum",
"chars": 82598,
"preview": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1"
},
{
"path": "jni/jni.go",
"chars": 15016,
"preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
},
{
"path": "metadata/en-US/full_description.txt",
"chars": 433,
"preview": "Tailscale is a mesh VPN alternative that makes it easy to connect your devices, wherever they are. No more fighting conf"
},
{
"path": "metadata/en-US/short_description.txt",
"chars": 28,
"preview": "Mesh VPN based on WireGuard\n"
},
{
"path": "version/tailscale-version.sh",
"chars": 1145,
"preview": "#!/usr/bin/env bash\n\n# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n# Use of this source code is gove"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the ppyyr/tailscale-android GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 43 files (276.5 KB), approximately 103.9k tokens, and a symbol index with 322 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.