commit 5109987e18945b268347a55fd436d6dccbb7bfca Author: Elias Naur Date: Fri Apr 17 14:07:22 2020 +0200 all: initial commit Signed-off-by: Elias Naur diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c67ccb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + +# The destination for the Go Android archive. +android/libs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eb7c3f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c8b92fe --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +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 + +all: $(APK) + +aar: + mkdir -p android/libs + go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -o $(AAR) tailscale.com/tailscale-android/cmd/tailscale + +$(DEBUG_APK): aar + (cd android && ./gradlew assembleDebug) + mv android/build/outputs/apk/debug/android-debug.apk $@ + +$(RELEASE_AAB): aar + (cd android && ./gradlew bundleRelease) + mv ./android/build/outputs/bundle/release/android-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) + +clean: + rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR) + +.PHONY: all clean install aar release diff --git a/PATENTS b/PATENTS new file mode 100644 index 0000000..560a2b8 --- /dev/null +++ b/PATENTS @@ -0,0 +1,24 @@ +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3be4972 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Tailscale Android Client + +https://tailscale.com + +Private WireGuard® networks made easy + +## Overview + +This repository contains the open source Tailscale Android client. + +## Using + +Available on [Play Store](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. + +``` +$ make tailscale-debug.apk +$ adb install -r tailscale-debug.apk +``` + +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. + +## 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. diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..7191462 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,43 @@ +buildscript { + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.6.0' + } +} + +allprojects { + repositories { + google() + jcenter() + flatDir { + dirs 'libs' + } + } +} + +apply plugin: 'com.android.application' + +android { + compileSdkVersion 29 + defaultConfig { + minSdkVersion 23 + targetSdkVersion 29 + versionCode 2 + versionName "0.1" + } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } +} + +dependencies { + //implementation 'com.google.android.gms:play-services-auth:18.0.0' + implementation "androidx.core:core:1.2.0" + implementation "androidx.browser:browser:1.2.0" + implementation "androidx.security:security-crypto:1.0.0-rc01" + implementation ':ipn@aar' +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..5bac8ac --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..490fda8 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a4b4429 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,183 @@ +#!/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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9109989 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,103 @@ +@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 diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7082d8b --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/ic_launcher-playstore.png b/android/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..00513bc Binary files /dev/null and b/android/src/main/ic_launcher-playstore.png differ diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java new file mode 100644 index 0000000..e975976 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -0,0 +1,81 @@ +// 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.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.BroadcastReceiver; +import android.net.ConnectivityManager; + +import java.io.IOException; +import java.io.File; +import java.io.FileOutputStream; + +import java.security.GeneralSecurityException; + +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKeys; + +import org.gioui.Gio; + +public class App extends Application { + @Override public void onCreate() { + super.onCreate(); + // Load and initialize the Go library. + Gio.init(this); + registerNetworkCallback(); + } + + private void registerNetworkCallback() { + BroadcastReceiver connectivityChanged = new BroadcastReceiver() { + @Override public void onReceive(Context ctx, Intent intent) { + boolean noconn = intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false); + onConnectivityChanged(!noconn); + } + }; + registerReceiver(connectivityChanged, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + 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 { + String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); + + return EncryptedSharedPreferences.create( + "secret_shared_prefs", + masterKeyAlias, + this, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ); + } + + private static native void onConnectivityChanged(boolean connected); +} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.java b/android/src/main/java/com/tailscale/ipn/IPNService.java new file mode 100644 index 0000000..b5bbe9e --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/IPNService.java @@ -0,0 +1,102 @@ +// 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.app.NotificationChannel; +import android.content.Intent; +import android.net.VpnService; + +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"; + + private static final String STATUS_CHANNEL_ID = "tailscale-status"; + private static final String STATUS_CHANNEL_NAME = "VPN Status"; + private static final int STATUS_NOTIFICATION_ID = 1; + + private static final String NOTIFY_CHANNEL_ID = "tailscale-notify"; + private static final String NOTIFY_CHANNEL_NAME = "Notifications"; + private static final int NOTIFY_NOTIFICATION_ID = 2; + + @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, GioActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); + } + + protected VpnService.Builder newBuilder() { + return new VpnService.Builder().setConfigureIntent(configIntent()); + } + + public void notify(String title, String message) { + createNotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_DEFAULT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, 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(NOTIFY_NOTIFICATION_ID, builder.build()); + } + + public void updateStatusNotification(String title, String message) { + createNotificationChannel(STATUS_CHANNEL_ID, STATUS_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_LOW); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, STATUS_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(configIntent()) + .setPriority(NotificationCompat.PRIORITY_LOW); + + startForeground(STATUS_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); + } + + private native void connect(); + private native void disconnect(); +} diff --git a/android/src/main/java/com/tailscale/ipn/Peer.java b/android/src/main/java/com/tailscale/ipn/Peer.java new file mode 100644 index 0000000..9a4a4ab --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/Peer.java @@ -0,0 +1,125 @@ +// 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.AlertDialog; +import android.app.Dialog; +import android.app.Fragment; +import android.app.DialogFragment; +import android.content.Intent; +import android.net.Uri; +import android.net.VpnService; +import android.os.Bundle; + +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.browser.customtabs.CustomTabsIntent; + +/*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;*/ + +public class Peer extends Fragment { + //private final static int REQUEST_SIGNIN = 1001; + private final static int REQUEST_PREPARE_VPN = 1002; + + @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + /*case REQUEST_SIGNIN: + if (resultCode == Activity.RESULT_OK) { + GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(getActivity()); + android.util.Log.i("gio", "Account: " + acc.getId()); + onSignin(); + return; + }*/ + case REQUEST_PREPARE_VPN: + if (resultCode == Activity.RESULT_OK) { + onVPNPrepared(); + return; + } + } + super.onActivityResult(requestCode, resultCode, data); + } + + @Override public void onCreate(Bundle b) { + super.onCreate(b); + fragmentCreated(); + } + + @Override public void onDestroy() { + fragmentDestroyed(); + super.onDestroy(); + } + + /*public void googleSignIn() { + GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .build(); + GoogleSignInClient client = GoogleSignIn.getClient(getActivity(), gso); + Intent signInIntent = client.getSignInIntent(); + startActivityForResult(signInIntent, REQUEST_SIGNIN); + }*/ + + public void prepareVPN() { + Intent intent = VpnService.prepare(getActivity()); + if (intent == null) { + onVPNPrepared(); + } else { + startActivityForResult(intent, REQUEST_PREPARE_VPN); + } + } + + public void showURLActionView(String url) { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(url)); + startActivity(i); + } + + public void showURLCustomTabs(String url) { + CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); + CustomTabsIntent intent = builder.build(); + intent.launchUrl(getActivity(), Uri.parse(url)); + } + + public void showURLWebView(String url) { + DialogFragment f = new WebViewFragment(); + Bundle args = new Bundle(); + args.putString("url", url); + f.setArguments(args); + f.show(getFragmentManager(), "urldialog"); + } + + private native void fragmentCreated(); + private native void fragmentDestroyed(); + private native void onSignin(); + private native void onVPNPrepared(); + + public static class WebViewFragment extends DialogFragment { + @Override public Dialog onCreateDialog(Bundle savedInstanceState) { + String url = getArguments().getString("url"); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + WebView wv = new WebView(builder.getContext()) { + @Override public boolean onCheckIsTextEditor() { + // Force the soft keyboard to appear when a text + // input is focused. + return true; + } + }; + wv.setFocusable(true); + wv.setFocusableInTouchMode(true); + wv.getSettings().setJavaScriptEnabled(true); + // Work around Google OAuth refusing to work in embedded + // browsers. + final String USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:61.0) Gecko/20100101 Firefox/61.0"; + wv.getSettings().setUserAgentString(USER_AGENT); + wv.setWebViewClient(new WebViewClient() { + }); + wv.loadUrl(url); + return builder.setView(wv).create(); + } + } +} diff --git a/android/src/main/res/drawable-hdpi/ic_notification.png b/android/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..e6aec75 Binary files /dev/null and b/android/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/src/main/res/drawable-mdpi/ic_notification.png b/android/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..129824e Binary files /dev/null and b/android/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/src/main/res/drawable-xhdpi/ic_notification.png b/android/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..8066819 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/src/main/res/drawable-xxhdpi/ic_notification.png b/android/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..615f569 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..a45d73c Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher.png b/android/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..40e1553 Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..520e026 Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..f866669 Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher.png b/android/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..48be17d Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7b0fc3f Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..7c4f14e Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7fea58c Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7605dee Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..361e863 Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..09be6ef Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..377ed1b Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..40a7366 Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5b31427 Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e4c5435 Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2aeeea9 Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/values/ic_launcher_background.xml b/android/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..16b8946 --- /dev/null +++ b/android/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1F2125 + \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..26382cd --- /dev/null +++ b/build.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# 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. + +set -e + +mkdir -p android/libs +go run gioui.org/cmd/gogio -buildmode archive -target android -appid com.tailscale.ipn -o android/libs/ipn.aar tailscale.com/tailscale-android/cmd/tailscale +(cd android && ./gradlew assembleDebug) diff --git a/cmd/tailscale/backend.go b/cmd/tailscale/backend.go new file mode 100644 index 0000000..a5f52dd --- /dev/null +++ b/cmd/tailscale/backend.go @@ -0,0 +1,287 @@ +// 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" + "path/filepath" + "reflect" + "time" + + "github.com/tailscale/wireguard-go/device" + "github.com/tailscale/wireguard-go/tun" + "golang.org/x/sys/unix" + "tailscale.com/ipn" + "tailscale.com/logpolicy" + "tailscale.com/logtail" + "tailscale.com/logtail/filch" + "tailscale.com/tailscale-android/jni" + "tailscale.com/types/logger" + "tailscale.com/wgengine" + "tailscale.com/wgengine/router" + "tailscale.com/wgengine/tstun" +) + +type backend struct { + engine wgengine.Engine + backend *ipn.LocalBackend + logger logtail.Logger + devices *multiTUN + settings func(*router.Config) error + lastCfg *router.Config + + jvm jni.JVM +} + +type androidRouter struct { + backend *backend +} + +const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go + +// 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, store *stateStore, settings func(*router.Config) error) (*backend, error) { + logf := wgengine.RusagePrefixLog(log.Printf) + pol := logpolicy.New("tailnode.log.tailscale.io") + b := &backend{ + jvm: jvm, + devices: newTUNDevices(), + settings: settings, + } + genRouter := func(logf logger.Logf, wgdev *device.Device, tundev tun.Device) (router.Router, error) { + return &androidRouter{backend: b}, nil + } + var logID logtail.PrivateID + logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000")) + const logPrefKey = "privatelogid" + 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) + engine, err := wgengine.NewUserspaceEngineAdvanced(logf, tstun.WrapTUN(logf, b.devices), genRouter, 0) + if err != nil { + return nil, fmt.Errorf("runBackend: NewUserspaceEngineAdvanced: %v", err) + } + local, err := ipn.NewLocalBackend(logf, pol.PublicID.String(), store, engine) + if err != nil { + engine.Close() + return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) + } + b.engine = engine + b.backend = local + return b, nil +} + +func (b *backend) Start(notify func(n ipn.Notify)) error { + return b.backend.Start(ipn.Options{ + StateKey: "ipn-android", + Notify: notify, + }) +} + +func (b *backend) LinkChange() { + if b.engine != nil { + b.engine.LinkChange(false) + } +} + +func (r *androidRouter) Up() error { + return nil +} + +func (r *androidRouter) Set(cfg *router.Config) error { + return r.backend.setCfg(cfg) +} + +func (r *androidRouter) Close() error { + return nil +} + +func (b *backend) setCfg(cfg *router.Config) error { + return b.settings(cfg) +} + +func (b *backend) updateTUN(service jni.Object, cfg *router.Config) error { + if reflect.DeepEqual(cfg, b.lastCfg) { + return nil + } + err := jni.Do(b.jvm, func(env jni.Env) error { + cls := jni.GetObjectClass(env, service) + 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;") + for _, dns := range cfg.DNS { + _, err = jni.CallObjectMethod(env, + builder, + addDnsServer, + jni.Value(jni.JavaString(env, dns.String())), + ) + if err != nil { + return fmt.Errorf("VpnService.Builder.addDnsServer: %v", err) + } + } + + // builder.addSearchDomain. + addSearchDomain := jni.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;") + for _, dom := range cfg.DNSDomains { + _, err = jni.CallObjectMethod(env, + builder, + addSearchDomain, + jni.Value(jni.JavaString(env, dom)), + ) + if err != nil { + return fmt.Errorf("VpnService.Builder.addSearchDomain: %v", err) + } + } + + // builder.addRoute. + addRoute := jni.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;") + for _, route := range cfg.Routes { + _, 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", err) + } + } + + // builder.addAddress. + addAddress := jni.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;") + for _, addr := range cfg.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", 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 = cfg + b.CloseTUNs() + return err + } + b.lastCfg = cfg + 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, + } + logcfg.LowMemory = true + 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 := wgengine.RusagePrefixLog(log.Printf) + b.logger = logtail.Log(logcfg, logf) + + log.SetFlags(0) + log.SetOutput(b.logger) + + 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) + } +} diff --git a/cmd/tailscale/callbacks.go b/cmd/tailscale/callbacks.go new file mode 100644 index 0000000..e5cfabe --- /dev/null +++ b/cmd/tailscale/callbacks.go @@ -0,0 +1,92 @@ +// 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 ( + "sync/atomic" + "unsafe" + + "tailscale.com/tailscale-android/jni" +) + +// #include +import "C" + +var ( + vpnPrepared = 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 struct{}, 1) + + // onPeerCreated receives global instances of Java Peer + // instances being created. + onPeerCreated = make(chan jni.Object) + // onPeerDestroyed receives new global instances of Java Peer + // instances about to be destroyed + onPeerDestroyed = make(chan jni.Object) +) + +var ( + connected atomic.Value +) + +func init() { + connected.Store(true) +} + +//export Java_com_tailscale_ipn_Peer_fragmentCreated +func Java_com_tailscale_ipn_Peer_fragmentCreated(env *C.JNIEnv, this C.jobject) { + jenv := jni.EnvFor(uintptr(unsafe.Pointer(env))) + onPeerCreated <- jni.NewGlobalRef(jenv, jni.Object(this)) +} + +//export Java_com_tailscale_ipn_Peer_fragmentDestroyed +func Java_com_tailscale_ipn_Peer_fragmentDestroyed(env *C.JNIEnv, this C.jobject) { + jenv := jni.EnvFor(uintptr(unsafe.Pointer(env))) + onPeerDestroyed <- jni.NewGlobalRef(jenv, jni.Object(this)) +} + +//export Java_com_tailscale_ipn_Peer_onVPNPrepared +func Java_com_tailscale_ipn_Peer_onVPNPrepared(env *C.JNIEnv, this C.jobject) { + select { + case vpnPrepared <- struct{}{}: + default: + } +} + +//export Java_com_tailscale_ipn_Peer_onSignin +func Java_com_tailscale_ipn_Peer_onSignin(env *C.JNIEnv, this C.jobject) { + // TODO(eliasnaur) +} + +//export Java_com_tailscale_ipn_IPNService_connect +func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) { + jenv := jni.EnvFor(uintptr(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.EnvFor(uintptr(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, newConnected C.jboolean) { + connected.Store(newConnected == C.JNI_TRUE) + select { + case onConnectivityChange <- struct{}{}: + default: + } +} diff --git a/cmd/tailscale/logo.go b/cmd/tailscale/logo.go new file mode 100644 index 0000000..af01750 --- /dev/null +++ b/cmd/tailscale/logo.go @@ -0,0 +1,217 @@ +// Code generated for package main by go-bindata DO NOT EDIT. (@generated) +// sources: +// tailscale.png +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) +type asset struct { + bytes []byte + info os.FileInfo +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +// Name return file name +func (fi bindataFileInfo) Name() string { + return fi.name +} + +// Size return file size +func (fi bindataFileInfo) Size() int64 { + return fi.size +} + +// Mode return file mode +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} + +// Mode return file modify time +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} + +// IsDir return file whether a directory +func (fi bindataFileInfo) IsDir() bool { + return fi.mode&os.ModeDir != 0 +} + +// Sys return file is sys mode +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + +var _tailscalePng = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x04\x00\x00\x00\x00\xca\b\x06\x00\x00\x00;!2B\x00\x00\x00\tpHYs\x00\x00\x9e\x1b\x00\x00\x9e\x1b\x01b8\xb7\xfd\x00\x00\x00\x19tEXtSoftware\x00www.inkscape.org\x9b\xee<\x1a\x00\x00 \x00IDATx\x9c\xec\xddw\x9c$e\x9d?\xf0\u03f7:UU\xcf\xec.\xbb \"(9\x83\bJ\x92\f\x82\xa0\xe8OP\f\x88\x8az\x9e'\x86\xd3\u00c0\xf1\xe4\x0e\xef\xf4\x8c\u725e\t\u00ddpg\x0e\x04A\x11AP\x82\x9c\x04%\a1\x00\xb2\xbb3]\xa1\xab\xba\xeb\xf3\xfbc\x06X\x96\x9deB\xf7<\xd5\u075f\xf7\xeb5/v{\xba\xab>\xc3\xf6t=\u03f7\x9e`\x10\x11YK\x9aF\xc7z\xc4\xf7\\\xe7\x98\x19/\xa9\ac\a\xb9N!\"\"\"\"2H<\xd7\x01DDDDDDD\xa4\xffT\x00\x10\x11\x11\x11\x11\x11\x11\x19\x01U\xd7\x01DD\u028ed\xc3u\x86\x19\xe4fV\xb8\x0e!\"\"\xe2\x12\xc9\n\xca\u066f\xa1\x99e\xaeC\x88\xac\xa9\x8c\xbf(\"\"\xa5\xc1(\xda4O\xe3?\xba\u03b1.\xf4p\x1c\x80\xef\xba\xce!\"\"\xe2R\xdeN>\f\xf2\xef]\xe7X\x87\xdb\x00l\xed:\x84\u021a4\x05@DDDDDDd\x04\xf4}\x04\x00\xc9J\xa7\x1d\x1d\xdc\xef\xf3\xccG\xb5k7X\xb3\xf9'\xd79DDDDDDD\xfam1\xa6\x004H\xbbp\x11\xce3g\xb9\xc7W\x008\xdbu\x0e\x11\x11\x11\x11\x11\x11\x91~\xd3\x14\x00\x11\x11\x11\x11\x11\x11\x91\x11\xa0\x02\x80\x88\x88\x88\x88\x88\x88\xc8\bP\x01@DDDDDDd\x04\xa8\x00 \"\"\"\"\"\"2\x02T\x00\x10\x11\x11\x11\x11\x11\x11\x19\x01*\x00\x88\x88\x88\x88\x88\x88\x88\x8c\x00\x15\x00DDDDDDDF\x80\n\x00\"\"\"\"\"\"\"#@\x05\x00\x11\x11\x11\x11\x11\x11\x91\x11\xa0\x02\x80\x88\x88\x88\x88\x88\x88\xc8\bP\x01@DDDDDDd\x04\xa8\x00 \"\"\"\"\"\"2\x02T\x00\x10\x11\x11\x11\x11\x11\x11\x19\x01*\x00\x88\x88\x88\x88\x88\x88\x88\x8c\x00\x15\x00DDDDDDDF\x80\n\x00\"\"\"\"\"\"\"#@\x05\x00\x11\x11\x11\x11\x11\x11\x91\x11\xa0\x02\x80\x88\x88\x88\x88\x88\x88\xc8\bP\x01@DDDDDDd\x04\xa8\x00 \"\"\"\"\"\"2\x02T\x00\x10\x11\x11\x11\x11\x11\x11\x19\x01*\x00\x88\x88\x88\x88\x88\x88\x88\x8c\x00\x15\x00DDDDDDDF\x80\n\x00\"\"\"\"\"\"\"#@\x05\x00\x11\x11\x11\x11\x11\x11\x91\x11\xa0\x02\x80\x88\x88\x88\x88\x88\x88\xc8\bP\x01@DDDDDDd\x04T]\a\x10\x91\xf21+n%*\xff\xe2:\xc7L\f\xb8\xcdu\x06\x11\x11\x11\x11\x91A\xa3\x02\x80\x88;~\x0f\xf2&\x83]_\x14\xb8\xb1\xf0\xbck\x83 \xb8\xab_?\xaf\xf4\x17I?O\x92\xa9\xf7\x92qG\xd2v6`\x87<\x8d7\xab\x00\xdeC\xef\x8cG\xbd\xaf\x1e\xfdN\x9a\xf1zd\xf6\xc85i\xea\xba\u01a9\xc7`\xf0\xd8E\x9e\u019d,\x89\xee$p\xab\x11\xb7\xc2\xc3o\xd1\u016fkax\xbd\n\x03\xe5C\xb2\x9ee\xd1\xf6(\xb03\xe0\xed\xe2\x91;\u04b0\x15\x80-rL\u0760\x98~\u00c0f\xb0u\xb6_8\xbb\xf6\xcbC\x9fA\x00\n\x02\xe8\x1ar\x0f@\x1a\xe7Y\x12\xddl\x86\x1b\v\xe2&\xd0n@\xa5\xb8\xa9^o\xfe\xde\u0332\xde\xfd\xb4\"\x8b\x87\xfc\xeb\x92NZ\u007f\x1a\xad\xb2+\xc8]\x01\xec\x06`\xdb\x1cS\x853\x9b\xfe\xbd\xb1\xa9\xbf\xe0\xe1\xbe\xc2\xc3\ax\xcc\x11\xf1\xf0S\u05fe\xa6O\x97\xfes\xaf\x82,\x89\"\x18n\x06\xed\x06cq\xbdyv}V\xd8o\xc20\xbc\xb7??\xa9{\xc6U\xab6\xe8\xe7\t\xe2Z-\xa8y(\xe7\xff@\xc3\xebki\xfeM\xd71\x00\x00K\x97N\fk\xa7\x93\xa4\x97e\xad\x1d\xad\xf0\xf6\x01\xb0/`\xcf\x04\xb8-\xca;\x05%\x02p\xb9\x01?\a\xbcK\xaa\xbe\xff+3k\xbb\x0e\xb5\xd8H\x06\x98\x9c\f\x1f\xff\x99\x8b\xac(\n[\xb6l\xe5b\x9d\x8eQ\xb4i\xee\u13cbu\xbe\xb9\xa0\x87\xe3\x1a\x8d\xe6w]\xe7X\f\x9c\x98\xd80\xabV\x0f2\xe3!\x00\x0e\x02\xb0-\xa6\xaa\xfe\xa5A\xe0O\x9e\xe1\n\x14\xbc\x02\xf0.\xad\x06\xc1\xaf\x86\xf5s}\u0431\xd5\xda8\xf3\xbc\x83\r\xc53a\xf6L\x00{\x00\xa8\xbb\u03b5\x96\x14\xc0\xb5 \u007fMx\x97\u0503\xf4\"\xb3\xe5\xab]\x87\x1a5i\x9an\u5c73/h\u03c4\x87}A\xec\x04\xa0\xe6:\xd7\f2\x00\xbf&p\x89g\xfcy\xb5\u047c\xcc\xccZ\xaeC-\x86,\x8d?\x02\xf2\xef]\xe7X\x87\xdb\xeaAsk\xd7!\u0288\x93\x93O\xc8*\x95C\r\xdc\x17\xc0\x010\xec\x8cr]\xd7\xef\x06x9\xcc\xfb%:\xc5\xcfk\xcd\xe6uf6C\xe9n\xb0X\x96DC\xf1\x83\f\xbc\x02{\u055b\u036b\\\xc7\xe8\x95$I\xb6\xacXq\xa4\x01\xcf&q0\x80\xa5\xae3-@\x02\u0615\xc6\u2f2eW=\xd7\xf7\xfd\xdb]\aZ\fy\x12\xbd\x9b\xc0\x87\\\xe7X\x87\a\xebAs\xc5b\x9dL\x05\x007HV:\xed\xe8`\xc2{6\xc0CA\xec\x8a\xf2\x16\r\u05cdXe\x1e.\"pA\xa7\xb0\x1f\x85a\xf8\a\u05d1F\x15\xc9J'm\x1dP\xa0r\xa4\x19\x8f\x04\xb1;\x1es\v\xbf\xf4:\x00\xae4\xe0|\x168\xaf\xdel\xfe\xdau\xa0a\xc4\xc9\xc9'dU;\u00a6>{\x0e\x03\xb0\xb1\xebL\v\xd0\x01p5\x81\x9fX\x85\xe7\xd6\xebc\u05f9\x0e\xd4/*\x00\f\x86,\x8a\xf60\x0f\xcf!p\f\x80\xbd0@\xd7u\x02\u007f2\xe0<\xd2~\\\x0f\x92\xf3\xcdVL\xb8\xce4_*\x00\x94\xc5\x10\x14\x00\xb28\xde\x1b\x1eN\x00\xf1\x1c\x80\u06fb\xce\xd3GW\x19yN\xc7*\xe7\x06Ap\xa7\xeb0\xfd\xa2\x02\xc0\x14\x15\x00\x16W\x16E{\xc2x\x12\u034e7`S\xd7yz\xecF\x02\xe7\xd2*_\xf5}\xff6\xd7a\x86\x1dI\xaf\x93\xa6\xfb\x92\xdd\xe3iv\x82\x01\x9b\xb8\xce\xd4cw\xd3\ucfc8\u0397|\u007f\xc9\xef]\x87\x19d\xed\xf6\xe4\x0e^\xe1\x9d@\u00f1 \xf6\xc0\x00uJ\xe6\xc6~O\xf0\x1cx<\xb7\xd1\x18\xfb\xad\xeb4\xbd\xa4\x02@y%I\xb2E\x95\xdd\x17\u04fcW\x02\xdc\xceu\x9e\x1ei\x93\xb8\u040c\xe7\xd6\xfc\xe6\xb7\x06m\xa4\x8d\n\x00e1\xa0\x05\x80\xac\xd5\u069dU\xef\xc5F\x9e\x00\xe0)\xae\xf3,2\x02\xf8%i\x9f\xa8\a\xc1\xb7\u032c\xe3:P/\xa9\x000E\x05\x80\xfe\x8b\xe3x\xf3\x8a\xf1\xf5\x06\xbc\x04\xc0\x16\xae\xf3,\x02\x02v1\x89\xcf\u05c3\xe0\u06da\xb3\xdb[i\x9anm(N\x01\xf9\x8a!\xec\xf4\xcf\xe4*\x00\x9f\xab\xf9\xe1\xd7\xcc,q\x1df\x10\xa4i\xbaU\x85\xdd\x17\x13x\x11\x80]\\\xe7q\xe0\x06\x18>]k\x84g\x9bY\xec:\xccB\xa9\x00P.$\x1bY\x1a\x1do\xb0\xbf\x05\xb0\x8f\xeb<}\xd6\x02\xf0\r\x148\xab\xdel^\xe3:\xccl\xa8\x00P\x16\x03T\x00 \xe9gIr\xac\x19\xdf\x04`?\xd7y\u0280\xc0\x9f\x00|\xae\x9eu>iK\x97>\xe8:O/\xa8\x000E\x05\x80\xfe\u0262hOx\xf6V\x80\u01e3\xbcsj\xfb\xed~\x9a}\xb9[\xe0Sa\x18\xde\xe3:\u0320\"\xe9eIr\x9c\x19\xfe\x16\xe0!\x18\xbc\xe1\xfd\xbd\xf2\x00\x81\xb3:\x05>\xd3l6K\xf9\xb9\xe5\x12\xc9J\xbb\x1d\x1f\xed\x11\xa7\x028\x14\xa3\xfb>Y\xd3j\x90_\xe9\xc0\xfb\xc8 \u007f\x06\xa9\x00P\x0el\xb56\xc9*v\xaa\x01\xa7\x00\xd8\xc8u\x1e\a~\x05\xf0\xd35\xbf\xf9\xdfe^\xc4uH\x878I?\xa4i\xbaM\x96D\x9f\xc9\xd3\xf8/f<\a\xea\xfc?\u0300M\rx_^\xaf\xdd\xd9N\xe3\u007f\xe5\u0295\xcb\\g\x12)\xabN\x1a\x1d\x9d%\xad\x9f\xc1\xc3U\x00_\x82\xd1\xed\xfc\x03\xc0FF\xbe\xbdj\xbc-K\xa2\xaf\xb4\u06edQ\xbc\x139o$\x83,\x8d\xfe&O\xa3\u07d9\xf1\u007f\x01\x8ez\xa7nC\x03\xde]\xf3pG\x96D\x9f\x8f\xe3x3\u05c1\u0280\xad\xd6&\xed$:#O\xe3{=\xe2{\x00\x0e\xc3h\xbfO\u05b4\x14f\xa7V\x8d\xb7fI\xf4\xa58\x8e\x9f\xe4:\x90\f\x9e$I\xb6\xc8\xe2\xd6'\xf2\x8a\xddn\xc0;1\x9a\x9d\u007f\x00\xd8\x1b\xb0\xb3\xf34\xbe%K\xe37\x91,\xdfb\xdaP\x01@f!\xcbZ\xbbeI|\xb6\xc7\xee\x8d\x00^\x0f<\xb4\x8f\xad<\x16\u01cd|[\xee\xd7o\xcb\xe3\xd6i$\u02f6\xaa\xb4\x883Y\x14=-K\xe2\x8b\v\u21c0\x1d\xe8:O\xc9\xd4\x00\x9cd\x85]\x97\xa7\u0477\xdb\xed\xc9\x1d]\a*\xb3\xa9\xe1\xa5\xf1\x9b\xf24\xbe\x13\xc4\u007f\x00\xb6\x8d\xebL%S\apJ\xd5xK;\x8d?\xdc\xef\x1d\x9f\xca*I\x92\xa7\xac\xd1)y\x17\x06{A\xbf~\xab\x038\xb9j\xfc};\x8d\xcf$\xef\x1fw\x1dH\u028f\xad\xd6&Y\x12}\xb6\x82\xe2\x16\x98\x9d\n p\x9d\xa9$\xb6\x00\xf9\xf1<\x8do\xcf\xd2\xe8oI\x96\xeaF\x87\n\x002\xa3,\x8a\x9e\xdeN\xa2\xf3\u0475\xdf\x00|9\x80\xaa\xebL\x03d9\xcd\xce\xcc\xd3\xf87\x9d4:\xcau\x18\x11\x97\xe28~R\x96D_\x86\x87_\x03<\xd8u\x9e\x923\x12\xc7Y\xe1\xfd_\x96D_\x8c\xe3xs\u05c1\u0284d%K\xa2W\xe5i|3\u020f\x03x\x82\xebL%\x17\x18\xf9\x0f\xb9_\xbb-K\xe37\x92\x1c\x89v_\x9a\xa6[eI\xeb\x1b\xea\x94\xccKh\xe4i\x9d4\xbc)KZ\xaf \xa9\x91\x12\xf2\x18$\x9by\x12\xbd7\xaf\xd8-\x00^\x87\xd1\x1e\u0277>\x1b\x83\xf8\xf7<\x8dnh\xc7\xf1\v]\x87y\xc8H\\\bdn\x92$\xd9\"K\xa2\xb3\xe0\xe1J\x03\x8ep\x9dg\xc0\xedP\x10?\u0292\xf8l\x92c\xae\u00c8,&\x92^\x1e\xb7\xfe\xbej\xfc=\x80W@\u05dc\xb9\xa8\x00xe\xd5\xf8\xbb<\x89\u07a5\xd1DSE\xe9<\x8d/\a\xf0\x05\x00Ov\x9dg\xa0\x10\x1b\x80\xfcd\u078e\u007f\x9dE\xd1\xd3\\\xc7\xe9\x17NL\xach\xa7\xf1\x99\x1e\xbb7\x00v\"\u02b5\xa7\xf8@!\xb0\x19`_\xce\xd3\xe4\xc2(\x8a\x9e\xe8:\x8f\x94G'\x8d\x8e\xc9\xd3\xf8\x06\x02\x1f\x00\xa0\xb6\xed\xac\xd86f<7KZ?k\xb7'wp\x9dF\x8d1y\x18y\xffx\x96\xb6\xfe\xad\x82\xe2\x16\x00\xaf\x85\xde\x1f=\u0117\xe7itM\x16E{\xb9N\"\xb2\x18\x92$\xd9\"O\x93\x8bh\xf6\x11\x00\xa5\x9c\x037 B\x02g\xe4ir]\x9eL\x8e\xe4\xe8\tNNn\x94%\u0457\xe0\xe1W\x00\xf6v\x9dg\xa0\x11O\x83\x87_\xb6\x93\xe8\x83e\x1b\x92\xba\x10$ky\xdc:-\xafU\xef0\xf24\x00\r\u05d9\x86\a\x0f\xady\xf8M'\x8d\x8eq\x9dD\u070a\xe3x\xb3<\x8d\xbeU\x10\xdf\xc7h\xec\xd8\xd3\av\xa0\x15\xde5y\x12\xbd\xcfea_\x1d<\x01\x00\xb4\xe3\xf8\xffu\xd2\xf0&\xd0\xde\x02\r\xe3\xe9\x13\xdb\x06\x1e.\u02d3\xd6;4\xa4N\x86Y;\x8e\x8f\xafXq-\xc0\x83\\g\x19\x1e\u071e\xf0.\u0292\xe8,\x92M\xd7i\x16K'\x8d\x8e\u026a\xdeu\x00N\x86\x16m\ub57a\x01\xef\xc9\xd3\xf8\xb2$I\x06~\xfb\xde\x81\xf7\xe7i|u\x96\xb5vs\x11@\x05\x80\x11\x17\xc7\xf1f\xed$\xfa\xa1\x19\xffgj\xb8\x97\xf4Y\x8d\xb0\u007f\xce\xd3\xe4\xeca\xba\xfb\"\x02\x00$\xebY\x12}\u064c\xe7\x80\x18\xc9E\xc7\xfa\xcc\x00\xbc6O\x93\xab\x87}4\x11W\xaf^\x9e%\xad\xff*\x88\xef\x1b\xb0\xa9\xeb\xc3sE\u04b24~\x13\x8d?\x83\x16\xf9[L\x1e\x81\x0ffI\xf4\xa5\xb2\x8fL#Ym'\xd1?\xc2x\x19\x80\xad]\xe7\x19a;U\x8d\x97\xa6i\xba\xad\xeb \xd2\x1f\xed8>\u044a\xcaU\x00\x9c/X7\nh86O\xeb\xbfj\xb7'\x17e4\x93\n\x00#&M\xd3m\xf24\xbe\x02\xc0+]g\x11\x00\xc0\xdey\u057bH\xc3\xe9dPeYk7\x8f\xdd+\x01n\xe7:\xcbh\xb2\x13\xf3v\xfc\x8bA_\xa5\x9bd#O\x93\xb3\xa7\xb7\xf6\x1b\xf9\x1d\x0f\x1c99O\xe3\x1f\x96ud\x1a'&6\xcc\xd3\xe4|\x03N\x87V\xf7/\x83-\x8d\xddK\x86a\x1d\ty\x04Ik\xa7\xf1\x99f\xfc\x06\x80\x91Yo\xa6\x1cl[+\xbc\xcb\xf2d\xf2\x90~\x9fI\x05\x80\x11\xd2I\xa3\xa3\xc0\x97\xb8\xce\"\xa8\x1b\xec\xec<\x8e\xdf\u06af\x13\xa8\x000\u4987\xf2|\x98\u018fB\xff\xdeewr'\x8dOw\x1dB\xe4\xf1\xb0\xd5\xda\xc4c\xf7\"\xed\x1cR:+h8?\x8f\xe3}]\a\x99\x8dv{rgc\xe5\xa7\xd0~\xd2%\u0103\xb34\xfe\x8e\xebN]\x96F\xaf5\xe39\x00JQ\x8c\x90\x99\u0601y\x9a|\xc1u\n\x99\x1f\xf2\xc1\xa5y\x1a\x9e\xaf\xad{K\xc5h\xfch\xbf\x16hU\x87p\x88\x91\xac\xe6i\xfcE#\xff\xc1u\x16\x99\x1d\x02\xef\u03d3d\u007f\xd79DfB\xb2\x92W\xec\xab\x00\xb6t\x9dE\u0585\xe34\xfe8\x8b\xe3\xbd]'Y\x9f<\x8e\xf7\xb3\xc2\xfb\x85\xb6\xf8+/\x03\x8e\xc8\xd3\xe8lW#\xd3\xf2$z\x0f\x88\xb3\xa0\xf9\xfe\x03\x82/\u0352\x96\u0597\x1a0\x9c\x98X\x91\xb7\x1b\x17\x01\x18\xa8\xd1c\xa3\x82\xc0\a\xf3$zw\xaf\x8f\xab\x02\xc0\x90\"\xe9\xe5i\xf2e\x00';\x8e\"s\xe3\x11\xc5\xd9e]\x84I$K\x93\x0f\x018\xccu\x0eY\xaf%\xf0x^\xbb\xdd\xda\xd5u\x90u\xc9\xe2xo\x1a~\f@\x8b\x9f\x96\x9e\x9d\xd0I\xe3E\xdf\"\xb0\x9dD\x1f$\xf0\xc1\xc5>\xaf,\x94}FR\xbf\xa3R\ny\xad\xf2O\x00V\xb8\xce!sD\xec\xdciG\xfb9\x8f11\xb1\xc2c\xf1\x03\x00Op\x9dE\xe6\x83\xe3Vx\xdf\xec\xe7\xce\x00\x9d4:\x1a\xe4\xbf\xf5\xeb\xf8\xb2\x88\x88\x0f\x92\u007f]\xe2:\x86\xac[\x96&g\x90x\xa1\xeb\x1c2gu\xc2\xfb\xdf4M\xb7Z\xe8\x81\u0539\x18\"\x9d4:\x8af\x1a:>\xe9:\x87\xf4\xdc\xe6Y\xbb\xf9\xde^\x1e0\b\x82;\v\xab\x1c\x06\xe0\xae^\x1eW\xdc2\xe0\r$C\xd79F\x1dI\xeb4\xea_\x01\xf0\x14\xd7Y\xa4\xa7>\xd5nO\xee0\x9f\x17\xaa\x000dja\xf8K\x148\x04\xc0\x83\xae\xb3H\xcf\xec4\u0750\x16YTy\x1c\xef\a\xe0\x99\xaes\u022ct\x8d|G=h\xbe\xc6\xccr\xd7aH6\xe1\xf1\xeb\x00|\xd7Y\xa4\xf7\x8c<\xb5\u075e\u063e\x97\xc7\xf4}\xff\xf6.\xbcg\x01\xb8\xad\x97\xc7\x15\xa7\x96di\xf4\x02\xd7!F]\u078e\xff\x96\x86\xe7\xba\xce!=\x17\x1a\xbd\xaf\x93\xac\xcd\xf5\x85*\x00\f\xa1z\xb3y\r\n\x1c\x06\u0bee\xb3H\x8f\x18Nv\x1dAF\x90\u01f7\xba\x8e\xd0g9\f+A\xacr\x1ddal\xd23\x1cW\v\xc7>\xec:\xc9C\xf24\xfe\x18`\u06fa\xce!}S\xb7\xa2\xf2\xa9^\x1f4\b\x82\xbb;\xb4\x83\xa1\"\xc0\xd00x'\xbb\xce0\xca\xd24}*\b-\x10>\xac\x88=:I\xf4\x96\xb9\xbe\xac\u068f,\xe2^\xbd\u067c6\x8b\xa2#\xe0a\x90\xf7\\\x8e`\xb8\x99\u011f=C\x8b\xc4\x04\x88\x98\x9e\xc5^Q\xac\x04\x80\xc2l)\xcc<+\x18\xc2l\x03\x82\xcbmj\x9b\xa9-\x00l\xe46~\x0f\x19N$\xf9\xf7f\xd6v\x1dEF\x03''7\u0289\xe7\xb9\u03b1\x00\x0f\x02v\x1d\xc0[\x8c\xbc\xb9\xeb\xd9\xcdf\u016d\xddn\xa5\x15dY\vK\x97N\x98Yw\xcd\x17\x90\xf4\xb1zu\x906\x1a\u02ea\xc0\xe6Dw+\u009ej\xe0V\xa0\xed\x04\xc3\xce(_\xe1\xfcnz\u0171\xd5\xc6\xd8\xff\xb9\x0e\xf2\x904\x8d\x8e\x05\xf1\x1a\xd79\xa4\xef\x0eK\xd3\xe8X\xdfo~\xbf\x97\a\r\xc3\xf0\x9e8\x8e\x0f\xae\x1a\u007f\n`\xc1\xdb]9\xd2\x06p+\x81{=\xc3j\x12\xad\xe9\xf6K\xcbc\xb1\x1aDQ\x98\x8d\xc1\xacf(\x1a\xa0-7b\x03\x1a6$\xb0\xa5\x01\x9b\x000\xd7?Do\xf0\xe0$I\xb6\b\x82@\xd3;\x16\x19\xc9J\x9e\xc6gc8\x17\xd8$\xa6\xa6\f\xfd\xc1\f\u007fb\xc1?\xd2\xf3R\x90m\x8f\x8c\v\xb3\x10f\r+8\x0e\xe3\x06\x80=\x19\xc0\x93\x01l\x8a!\xeb\xff\xd2\xec\x03i\x9a~\xc7\xf7\xfd\x9bg\xfb\x9a\xa1\xfa\x1f \x8fVo6\xaf\u0262\xe8\xb0\xe9\"@\xd9\xf7\xef\xbe\x01\xc4O\xe1\xe1\x06cqs\xce\xca\xcda\x18\xfea!\a$\x19\xe6q\xbc\x1d<\xee\x06\xc3n\xa0\xed\x0f`\x0f\f\xe6N\t\u02f3$y.\x80s]\a\x91\u0450W\xed\x18\f\xd6\xefJ\a\xe0/\rv\x01i\x17\u0502\xe0\xea\xb5;\xf8\x8f\xc7\xccR\x00)\x80\x95\x98Z\x90\xec\xe7k~\x9f+W.\xeb\x06\xf5\xfd\xbb\xb0\x03\x8c<\x10\xc0\xdep\xdbH\xbf\xb2\xd6\xe5q\x16\x8c\xfd\xd9a\x86G!\xef\x1f\xef\xa4\xf8\f]\a\xe9\x13\x03\xfeX\x00\xd7\x1b\xf9;xv\x93\xb1\xf8\x1d\vo\xa2\xa8TV5\xf2|\x12cc-\x00\xc0\xea\xd5>\xaa\xd5Fn\xb6\xa9W\xc1f\x05lK\x90\xbb\x02\xd8\rS\u05e19\x0f\xd9,#\x0f\xf8\x00\xc9\x1f\x98YO\xff\u0267\x8b\x00\aU\xad\xb8\x18\xb0mzy\xec>\xb8\x03\xc0Oav\x9d\xa1\xf8}\x17\xd5[\x1a\x8d\xc6]s\xfd\xfcY\x13\xc9F\x96\xb5\xb6B\xb7\xb2+<\xecn,\xf6\x05\xec\x19\x00\uaf4b\xbdh\xbc*\x8a\x97\x03\xf8\x90\xeb \xa3&o\u01ef\x03\xb0\x9f\xeb\x1c=\xf2\x17\x80\x17\xc3\xec\x12\x14vm-\bn4\xb3\xd6\\\x0fB\xd2\u03d3d7x\xdc\x13\xc43\x00\x1c\t`\xe3\xde\xc7]T\xbe\xb1\xfb)L\xfd,\xb3\xa2\x02\xc0\x90\xab7\x9b\xd7fY\xebPt\xed'\x006t\x9dg\rw\x01\xb8\x90\xb4\x8b\xebE\xf1S\x1b\xeb}\x03\xd6\xccb\x00\xd7N\u007f\x01\x98j\xc0g\x8d\xc6\xe1\x9e\xc7\x13H<\a@\xd0\xeb\xf3\xf6\x8b\x19\x8f\x82\n\x00\xb2H\x8c\xf6<\x0e\xc6\xfd\xa7{\x8c\xfc\xf7j\x81/\xdb\xd8\xd8_\xfay\"\xdb`\x83U\x00~0\xfd\x85$I\xb6\xac\xa2x)\x81\x97\x02\x98\xd7B<\xf3\xc7\xff\xae\xf9\xcdW\x99Y\xb2\xb8\xe7]\xbf<\t\u03c0\xe1I\xaes\xf4PL\xe0\x123;\x9f\u05bd\xa0\xde\x18\xbfi\x96\xaf{\xe8\xdf\xe5\xcfX\xe3\x1a\x04\x00$\u01fa\xed\xf8YE\x81\xa3a8\x1eS\xa3\xd6\x06\x13\xb1G\x96\xc5\xcf\x05\xf0\xdd^\x1f:\f\xc3?DQtP\u0373\x8b\x01n\xd7\xeb\xe3/\xc0\xfd\x80]\b\xf0\xe2.\xbc\x8b\x83 \xe8\xf9\xee\x05\u04e3\xfdn\x9c\xfe\xfao`\xea\xa6F\xb7\x1d\x1fX\x10\xc7\xc3p\x1c\x88\rz}\xde~!p4T\x00XTl\xb56\xce\v\x9c1\xe0\xe3H\xee\xa2\xd99\xd6\xe57\xeb\xcd\xe6\u057d8\xe0t\xa1\xff\xca\xe9\xaf\u03d0\xf4\xf28\u0793\x1e\x8e\xf3\x80W\x10\u062c\x17\xe7Yl\x06\x1c\u044e\xa2\xe3\x1a\xcd\xe6wf\xf5\xfc,\x89.\xecs\xa4J\x89\xf7\xff\xbd\x01\xc0\x9f\\\x87\x00\x80\xc2*o\x98\xcb\u040d\xb9j\xb7[\xbbXa?\x81\u06c6F\f\xe0\u007f\f\u0157\xaa\xfe\xd8%\xbd\xbec0W\\\xb5j\x83N\xbdz\n\xcd\xde\b`s\x97Yf\xe9\xcez\xd0\\\xb4\x15\\\xf3$z7\xcby\xc1~\xb0\x1e4\x17mD\v\xa3h\xd3\xdc\xc3\x1f\x17\xeb|sA\x0f\xc75\x1a\u035e7\xbcI\x86y\x1a\xdf\x0f\xa0\u032b7?\b\xb3\xf7\xd5\x1a\xc1\xe7\xcb05&\x8b\xa2\xa7O\xad\x99`/B\u007f\xa7\t\x90\xc0\a\xeb~\xf8\x01\u05df\xa1k\x9b\xfa\u007f\x80+0X#G\u058d\xb8\x0e\x1e\u03aa5\u04af\x9b\xad\xe8\xdb\xf6\xba$+\xddv|D\x97x\x93\x01G`\x10\x87}\x1b\xae\xad5\xc2=\xfb\xf5~\x9c\xfa\f\xb6\x8b\x01\xf6t\xd1\xc19\u028d\xf8Q\xd7\xc3\x17\x1b\x8d\xf0\u01ee\x17\xda$\x19\xe4i\xfcR\x00o\x06\xb0\x93\xcb,\xb3\x94\xd7\xfcp\xf9|\xee\xd8\xceV\x96\xc6\x1f\x01K\xb9k\xcdm\xf5\xa0\xb9\xf5b\x9f4K\xe2\xaf\x02\x1c\xd4m\xa4\u007f\xe2\x19>V\x99\xfa][\xb4\xeb\xdc\xf4\xe7\xf1Q\x05q*\x80\xc3\x17\xeb\xbc=tG\xcd\x0fw\x9c.r\xacW\xdf/4\xd3\r\u0268\xdf\xe7\x99\x1f\xbe\xa2\x1e\x8c\x9d\xed:\xc5bi\xb7'w\xb2\u00bb\b\x8b=\xd4\xc5p\r\x88\xcf\xd6\xfc\xf4\x9b\xfdlH\xcd\x17I\xbf\x93Fo&\xec\xdd(\xf9<\xa9.\xbc\xa7\xf6\xe3n\u00fa\xa8\x000e\x14\v\x00y\xda:\x82\xb4\xf3{}\xdc^1\xe2{\xd5nq\x8a\x8d\x8f\xdf\xef:\xcb\xda\xda\xed\x89\xed\xad\xa8\xbe\x1b\xe0\x89\xe8}G8%\xedU\x8d0\xfc\xaf\x1e\x1fw\xc1HZ\x9e\u0197b\xc0\x87\x9b\x1a\xf1}\xc2\u03a8\x87\u154b}\xeev{rg\x8f\xde?\x928n\xb1\u03fd@-z\u077d\x1a\x8d%\xbf\xeb\xd7\t\xd8jm\x92W\xec\"\x00;\xf6\xeb\x1c3\xb8\xcd\xc8\xff\xa8\x16\xf8Z\xbfG\x18\xcd\aI/O\xe3\x93\t\x9c1\xbdn@iy\x86\xa3\xab~\xf3\xc7\xfd:\xbe\n\x00\x8f\xc8\xe2\xf8\x190\xfe\x12\x03WP\xb4\x8bP\xf0\xb4^\xdd\xed_\x88<\x8e\xf7\xa3\xe1\x03\x00\x0fu\x9deNho\xad\x87\xe1\xc7\x1e\xefie[\xccH\xfa\xa8\xd1\x18\xbf\x81^q0\xa7\x86$\xf6\x1f\xf1\u007f\xf4p\\\xad\x11>\xbd\x1e4?_\xc6\xce?05\x1c\xa8\x16\x8c\x9dYXew\x00W\xb8\u03b3>\x15\x14e\x1dM#\u00e4\xc0\xee\xae#\u0304\xc0?U\x83\xf0\xb82v\xfe\x01\xa0\xd1X\xf2\xbbz\x10\xbe\xbc\xb0\u028e\x80]\xdc\xc3C\xff\x05\xb4\x83\xcb\xd8\xf9\a\x80,I\x8e\xc7`w\xfe/7x\a\xd4\xc2\xe6s]t\xfe\x01\xa0\xd1\x18\xbf\xbe\xe67\x9fo\xf0\x0e\x00p\xbd\x8b\fs\x94\x81\xf8T\xadSl\xd5\xcf\xce?\x00\xd8\xd8\u061fk\x9d\xe2`,\xde\xff\x97\xbb\x00\x9cR\xf3\xc3\xedk\xe1\xd8G\xcb\xd8\xf9\a\x003+\xeaA\xf3\x8b\xf5\xbc\xbb\x8b\x19f5\xf4\u05d5.\xec \xd7\x19F\x86\x15\xff\x8a\xc1\xea\xfc\xdf\xe9\x19\x9eS\x0f\xc2\xc3\xca\xd0\xf9\a\x80Z\x18^V\x0f\xc2\xc3H{\xa1\x01\xf7\xba\xce3k\xc6w\x92\xf7\x8f?\xde\xd3T\x00\x181\x8d\xc6\xf8M\xf0\xba\a\x1b\xfazG\xf3&\xd2^T\v\xc2=\x1a\x8d\xe6w\xcb6Lu&\xbe\xef\xdfV\xf3\u00c3\x00\x9e\xe3:\xcb\xcct\x01\x95\xfe\xa3y\xbb\xba\u03b0.4\xfb\x97F\xd0|\xf7 |\xa6\xf8\xbe\u007fs\xcd\x0f\x0e\x03\xf0\x1a\x00\xab\x17x\xb8\xdfv\xe1=\xa3\x1e\x86\xa5,P\x92l\x98\xb14[\x10\xce\t\xb1\x8a\xe0\xcb\xeaAs\xbfZ\x10\xfc\xc2u\x1c\x00\xa8\x05\xc1/j~\xb8'\x81\x0f\x00\x98\xf7Br}T\x00\xf6\xd5.\xbc\xed\xeaa\xf3T\x1b\x1f\xbfo1Nj\xe3\xe3\xf7\xd5:\xc5! \xfa\xb6\xe3\x85\x01\xf7\xc2\xf0\x86\x9a\x1fn[\x0f\x9a_0\xb3N\xbf\xce\xd5K\xb6d\xc9\x03\xd5F\xf8\x02\x9a\x95\xf6\xf7\xd0\u0203\\g\x18\x05\xed(:\x0e\xb0\x03\\\xe7\x98%\x028\xab\xe6\u01fbV\xfd\xe6\x8f\\\x87Y\x97F\x18\xfeo\xd5Ow\x04\xf0%\xd7Yfi\xa3N\x1a\xbe\xf9\xf1\x9e\xa4\x02\xc0\bj4\x96\xfc\xaek\x95\x83\xfbP\u044aA{k\xcd\x0fwi\x84\xe19fV\xf4\xf8\xf8}gf\xed\x9a\xdf<\xd1\f\xdfr\x9de\x9d\x8c;\xbb\x8e \xa3\x80\xbb\xb9N\xf0X\xbc\xb4\xde\b\xde\xe5:\xc5\\\x98\x19\xebA\xf3?;\xb4\x9d\b\xcck\xe8+\x81\x1f\xd5\xfcx\xbf2o\xa1\x95\xb7\xe3W\x03\xd8\xd2u\x8e\xb9\xb3\x8b;\xb0]\x1b\xc1\xd8\xd7]'Y\x9b\x99e\x8d\xa0\xf9~Cq\x18K\xb2V\x110=E\xc2+v\xab\a\xe1IA\x10\u0739\xe8\xe7\x1f\x1f\xbf\xbf\xd6\xe9\x1e\n\xc3oz|\xe8.\xcd>R\xf5\xc3m\xea~\xf33f\x96\xf5\xf8\xf8}gfl\xf8\xe1;J\\\x04\u0619\x1c\x90\xa5e\a\x14I3\xc3\a\\\u7625\x16i'\u0503\xe6\u07d8m4\xe9:\xcc\xfa\x98\xad\x98\xa8\a\xcdW\x01|\x05\x80\x92Nk\u007f\x04\x817\x93\\\xef\x94f\x15\x00F\x94\xef\xfb7w\xadr\x90\x01\v\xdaj\xef\x11\xbc\xb4\xb0\xcan\xf50\xfc\xd8B\xb6\xbe)\x033+\xaa\x8d\xf0\xe5\x98Z}\xb7\\\x88mt\x01\x95~\"Y\x03P\xa6\x15\xb7\x01\x80\xf4\xf0\x86A\xfdl\t\xc3\xf0\u07ba\x1f\x1e3\u71b9\xf1\xe3u?|n\x99\x1bG$\x1bF\xbc\xd3u\x8e9\xa2\x01\xa7\xd7\xfc\xe0\xf00\f\xefq\x1df}j\xc1\xf8\u03fa\xb4g\xc0\xf9\x94\x00\xfe\xdch\xfb\xd5\xc2\xe6s\x1b\x8dq\xa7Yl\u0252\ajY\xf70\x18\xae\xe9\xd1!o\x04m\xbf\x86\x1f\xbe\xbdl\xbbj\xccG\xbd\x11\xbck\xbe\x05\xc7>\v\x93$\x19\xc8\x15\xd6\aE\x96$\u03c7\xa1\x94#\xf8\xd6r;\xbdb\x9fF\x18\xfe\x8f\xeb sQ\x0f\xc6\xceF\x81\x83\x00\x94r\n\xe2\x1a\x96\xe7I\xf2\x9a\xf5=A\x05\x80\x11\xe6\xfb\xfe\xad]\xab\x1c\x04\xe0\xee\x05\x1c&\x82\u06695\xbfy\x90\xef\xfb\xb7\xf6(\x9asf\x16\x83\xf6jL\rO*\x931\xc4q\xa9\x17\xfa\x91\x01\xd7j-C\xe9\xf6(\xb7\x8b\x1b\x8d\xb1\u07faN\xb1\x10fV4\xfc\xf0\x1d\xd3w\x10\x1eo\u05c2\x0e\f\u007fS\xf7\xc7\xdeR\xf6\xa2G\u078e_M\f\u0536\u007fm\x82/\xab\x05\xcd3\x06e\x94Z\x18\x86\xf7\xd4\xfc\xf6\xfe\x00\x16\u007f\x8a\x02q\xdd\xd4\xdc\u0731\x03kax\xf9\xa2\x9f\u007f\x06\xb6d\xc9_ki~\x18\f\v\x99/\xdc!\xec\u031a\x1f\xee\xe9j\u0747~0\xb3\xa2K{-`\xa5+\x1c\u05ac\xbb\x8d\xeb\f\u00ca\xa4\x99\xc7\xd3]\u7605\x9b\xf2\x02\a\xb8.$\xceW\xbd\u067c\xaa\xb0\xca\xfe\x00\xeet\x9de\xbd\x8co\x99\xbe\xa1\xb3N*\x00\x8c8\xdf\u05d6R\xb5\x00\x00 \x00IDAT\xf7o\xebN-8t\xfb<^~\x0f\n\x1cP\xf7\xc3O\rJCj.\xeaax\x85qj\xbf\xef2\xe9x\u07a2o'#\xa3\xa3]\xab-s\x9d\xe11\x88\x1f\xba\x8e\xd0+\xf5`\xecl\xa3\x1d\x02\xc3\xcau>\x81Xe\u01a3\xea~\xf3\xacE\x8e6g$+ \u02b8\xea\xf6\xba\x11\xab\f\u0791\x8d`\xec\x1b\xae\xa3\u0315\xd9\xf2\xd55?>\x1a\xc0bu\xc2o#\xf8\xd2Z\x10>\xad\xacssm\u0672\x95\xb5F\xfbPL\xed\xe7=W\x0f\x9a\xf1\xa8F\x10\xbes6[f\r\x9a0\f\xff@C\xe9>C\b\xb5_\xfa\xa5\u04ce\x0e\a\xb1\x87\xeb\x1c\xebE\xfc_\xadS\x1c\xd4l6K\xb9\xb3\xd2l\xf9\xbe\u007fs\x17\xde!e\x9a\x9e\xb5\x0e\x9bgI\U0008267e\xa9\x02\x80 \b\x82\xbb\xbb\xf0\x0e\x010\x87\xed\xe5xI-\xef>\xad\xdel^\u06f7`e\xe0\xf1\u04ee#\xac\x8d(\xb6u\x9dA\x86\x97\xd7\xed.u\x9damfE\xdf\x16\xfdr\xa1\x16\x86\x97\xa3\xb0\xa3\xd6q\x87\xeeVV\xba\xfb\xd4\xfc\xb1\x9f8\t6GY\x92<\x0f\xc0S]\u7625\xd8`\xc7\u0502\xe0\x12\xd7A\xe6\xcbl\xa3\xc9Z\x9a=\a}\x9c\x9eF\xe0O\u04cb\xe0\xed\xd0\b\u01beQ\xf6\xe2\xfeTa\xa4}$\xe6V\x04\xb8\xa9\xb0\xca3\x06\xe5\xf7l\xbe\n\u06a7Q\xb2Q\x8c4S\xfb\xa5O\n\xda\xe3.\xfc\xe6\x92\x01\xf7v`\xcfY\xacEC\xfb-\b\x82;\xe0\x15G\x00x\xd0u\x96\x99\x98\x15\xaf\x9f\xe9{*\x00\b\x00 \b\x82\xbb\xba\xf0\x0e\xc2\xecF\x02\x9cU\xf3\x9b\x87\u06d2%\x0f\xf45T\tT\x1b\u035f\x82X\xe5:\u01da\fx\xa2\xeb\f2\xbc\xccc\xe9\n\x00,\xac\x94[\x88.D=\f\xaf4\u0631\x00\xe2\xa9G\xf8\xf3Z\xde}f\xbf\xb7S\xeb%3\xbe\xc5u\x86Y\xca=\xc3\t\xb50\xbc\xccu\x90\x85\xb2\r6X\u0545\xf7\x1c\x00\xbd\u0756\x8eXe\xe0\xbb\xea\x8f,\x82\x97\xf7\xf4\xf8}4U\x04H\x8f\xc0,FGL-\xaa\xd9\xdeg\x98\xa6,\xce$\b\x82\xbb`(\xd5M\x1a\xa3\xda/\xfd\x90\xa6\xe96\x06\x1c\xe9:\xc7zL\x14\x1e\x8f\x0e\u00f0G\ub395C\xa31~\xbd\x81'\xa0\x9c\xbb\xb5\x00\xb0\x03\xdb\xed\xd6.\xeb\xfa\x8e\n\x00\xf2\xb0 \b\xee\xee\xd0\x0e\x020\xe3\x85\xd1\xc8\u007f\x98Z\xb1sp\x1a\a\vaf9\xccJ\xb1'\xe9C\n\xb3\xf5\xae\xec)\xb2 \x86\x8a\xeb\bk\xf3*x\x82\xeb\f\xfdP\v\x82K\xcc\xf8\x02\x00\x9f\x9b.\xaa\xfe\xd5u\xa6\xd9j\xb7[\xbb\x02\xd8\xdfu\x8e\xd9\xe1k\xab~sh\xa6\x91\x04Ap\xa7\x19OFo\xee\xee&4\xfb\x97Z\xde\u066a\x16\x8c\xfd\xb3\x99\x95~\x85\xebu1[1Q\xf3\xe3gc\xfd\xeb$\x9c5\xb5\xa8\xe6\xf2\x85n\xcb98\n\x94j\xebP{\x9c\x95\xc9e~\xbc\xa2\xf3\x06\x94\xb7OGz8\xa9\xd1\x18\x1b\xaa\x91|\x0f\xa9\x05c\x17\x118\xc3u\x8e\x99\x18\xf1\xcau=^\xd67\x8b8\x12\x86\xe1=SE\x00\u07bc\xf6\xf7\x8c\xfc\x87Z8\xf6\xaf\x0eb\xb9E\x96\ua39c\x15\xba\x80J\xff\xb0\xf0J\xd78\xee\x12{\xbb\xce\xd0/5\u007f\xec\xfcz\xd0|\u0760m;f];\xc5u\x86Y\xfal=\x18\xfb\xb2\xeb\x10\xbdV\xf3\xc7\xce\x03\xb1\x90)j\x1d\xc0\xbe\u0685\xb7c\xc3\x0fO\xb3\xa5KK;\x8cu\xb6\xcc6\x9a\xac\xf9\xe1Q\x00\u007f\xbe\x8eo\u007f\xbe\u61ef/\xfb\xa2\x9a=\xe7Y\xa9\xda/\xd4\r\x8c\x9e#Y\x87\xd9K]\xe7\x98\x11\xf1\xe9F\xa3\xf9]\xd71\xfa\xa9\xee\x87\x1f\x00P\xca\x11f\xa4\x9dH\xf217vT\x00\x90\xc7\b\xc3\xf0\xdeZ\x17\xcf\x02p\xc3C\x8f\x19p\xfaHv\xfe\x01p\xa6\u017a\\14]G\x90\xe1\xc5J\xb7t\xc3\xed\rx)\u026a\xeb\x1c2\x85d\x03\x86\x13]\u7605\xdf\xd6\xfc\xf0\xad\xaeC\xf4K-\b\xdf\x0eb\xaew\xd5h\x86s\v\xab\xecT\x0f\u0093\x82 \xb8\xb3\x1f\xd9\\1\xb3V\xcdo\x1e\x05\xd8\xc5k<\xfc\xe5\x9a\x1f\xfe\x8d\x99\x95j>\xfcb \x8br\xb5_@\xb5_z,K\x92\xe7\x02\xd8\xd0u\x8e\x19\xdcP\v\u00b7\xbb\x0e\xd1ofV\xd0+^\x83\xc7\xdf\xe1g\xd1\x19\xb0I\xa7\x1d\x1d\xb6\xf6\xe3*\x00\xc8:\xd9\xd8\xd8_j]\x1e\x06\xe0&\x03\xdeS\v\x9a\xa5\x1d\xde\xd2o\x1eP\xaa\xe9\x0efP\x05]\xfa\xa6\xd3)\xdf\b\x00\x00[\xe5I\xf2F\xd7!dJ\xc9\x1b\x9c\x0f\x89\v\xab\xbcp\x18\xf6u\x9f\x89\x99\xb5Q\xe5I\x98\xe55\x8a\xc0\x05(\xb0W\xcdo\x9e\xe0\xfb\xfecF\xf9\r\v3\x8bk~p\xect\x11\xe0\xec\x9a\x1f\xbe\xba\xec\x8b\x19\xf6\r\xbd\x8e\xeb\bkQ\xfb\xa5\u05e6\xa6\x03\x95Qa\xb4\u05d9Y\xe9:\xc5\xfd\xd0h\x8c\xdfd\xc0?\xbb\u03b1.\xa4\xbdd\xed\xc7T\x00\x90\x19\xd9\xd8\u061fk~\xb8W-h~\xc8u\x16\x97h\xe5Z\x14\xad\xa0.\xa0\xd2?a\x18\x96\xb1\x00\x00\x18\xcf\xc8\xe3x\x1f\xd71\x04\xf0<\xbe\xd8u\x86\xc7c\xe0\x87\x86\xb9\x93\xfb\x90z}\xec:\xf0q\xb7{\xfb\x95\x81\x875\x82\xe6\x91\xf5f\xb3Tk\xda\xf4\xcbt\x11\xe0\x98\x9a\x1f\xbejd;\xff\x00\xcc\xe3\x12\xd7\x19\u05a2\xf6K\x0fq\xe5\xcae\x06\x1c\xee:\xc7\f>?\f\v\xaf\xceE\xd5\x0f\xff\u01402.tx\xcc\u06a3(U\x00\x90\xf5\x1a\xd4\x05\x81z\xc9\n\xdb\xc6u\x865\x19\u02b7H\x9b\f\x0f3\x8b\b\xfc\xd9u\x8eu\bh\xfc^\x16E{\xba\x0e2\xca\xc8\xfb\xc7I\x1c\xe5:\xc7\u3e35\xea7\xff\xcdu\x88\xc5R\xeb\x16\x1f\x04\xb0\x8e\xa9;\xf6;\xd2N\xa8\xf9\xe13k\xc1\xd8E\x8b\x1e\xcc13KFn\xce\xffZ\b\xb5_\x86Y\xee\u05de\x0f\xa0\xee:\xc7:L\xd4\xf2\xee\xe9\xaeC,63Kh\xa5\\\x10py'm\x1d\xb0\xe6\x03*\x00\x88\xac\a\xc9\n\r\xba\xeb(\xa3\xe6:\xd7\x01f\xb0!<\\\u048e\xe3\xe3]\a\x19UY\xd2<\x06@\xe0:\xc7\xfa\x98\xf1\r\xa32\xec\x14\x00l|\xfc~\x03>\xb2\xc6Cw\x03|U\xcd\x0fvn\x84\u1e638\xf7]\xa6\x18\xf9,\xd7\x19\xa4\u007f\b+\xe5\xb5\u0400\u007f\x1b\x85\xad\xc2\u05e5\xd6\b\xbf`\xc0\xbd\xaes\xac\x8d\xb4\xe7\xad\xf9w\x15\x00D\u05a3\u06ce\x8fD\xf9\u7e8a\xf4\x96Y\x99\xb7\xebi\x9a\xf1\x9c,\x89\xcf\xe6\u0295\xcb\\\x87\x195f\u0171\xae3\xac\x9f]T\xf3\xc7.p\x9db\xb1U\xfd\xf0# \xae3\xf2\x1d5?\u072e\x1e\x8c}i\xd4\xef~\x8f\xba4M\xb7\x01\xb0\x97\xeb\x1c\xd2\x1f$\x9b\x06\x1c\xe2:\xc7:lf\xa9\xeb<\xe2\x9e\xc7\xe2\xbd\x00\xccu\x0e\xe9\x8fn;>\x18@\xc3u\x8e\xb5\x11\xf8w\xb3\x15\xa5\xdbMh1U\xb3\xee\xe7\x00\u012es\xace\x878\x8e\x9f\xf4\xd0_T\x00\x10\x99A'\x8d\xdf\x05\xe0\x99\xaes\x88,6Z\xf5J\xd7\x19fi\x05\xcd\xce\xcc\xd3\xf8\x86,\x8d^K\xb2t\x8d\xa1a\xd2I\x92g\x00X\xe1:\xc7z\\Q\v\xc6\u007f\xea:\x84+\x1a\xea/\x0fi\xc7\xf1\t\x00_\xe6:\x87\xf4OQ\x94r-\x96v\xbd\xcb\u03fa\x0e\xe1\x9a-]\xfa \xc0\xef\xba\u03b1\xb6\xaa\x15\x87>\xf4g\x15\x00D\xd6!K\xe37\x12\xf8\xa0\xeb\x1c\".\xf8\xbe\u007f+\x80\x1b\\\u7603\xadA\x9c\x95\xa5\xf1\x1d\xed$\xfa\xc7\u986f\xd2k\xc6C\x1f\xffI\xee\x14V\xce-\x98D\x16S'\x8d\x8e1\xe3\u066esH\x9f\x19\u02f8\xfa\xff\u007f\xd9\xd8X\x19\x17\x11^t\x9e\xd9\xd7]gx,\xdb\xff\xa1?\xa9\x00 \xb2\x06\xf2\xfe\xf1,\x89\xbe\x04\xf2\x93\xd0\xd09\x19a\x04\xbe\xed:\xc3\\\x19\xb0\xa9\x01\xa7{\xec\u079c\xa5\xd1UY\x1a\xbf\x89\x13\x13Z\u00e3G\b\x94yA\xb1\xbf4\x1a\xe1\x8f\\\x87\x10q\x85d\xad\x9dD\xffX\x10\xdfA\t\x87\x86K\xef0\x8a6E\xc9vx\x00\x00\xa3}\xceu\x86\xb2\xa84\xc2\v\x00\xfc\xd5u\x8eG1<\xbc\x8b\x92\n\x00\"\x00H\xfaY\x1a\xfdm\x9e\x86\xb7\x008\xd9u\x1e\x11\u05ec\x18\xbc\x02\xc0\xa3\x10{\x82\xfcx^\xab\u0713%\xad\xffn\xc7\U00049718(\xf3\xf0\xf5R\x9b\xdeC\xb8\xb4S\xa2hv\xb6\x99u\\\xe7\x10Yl$+\xed8>!O\xa3\x1b\f8\x1d\xdajo\xe8e\xf6\u021d\xdc\x12\xb9\xad\x1a\x04W\xb8\x0eQ\x16\u04cb\x01^\xe8:\u01e3\x10;\x93\xf4\x01\xa0\xea:\x8b\x88+$-\x8f\xe3=Q\xe1K\xf24>\t\xe5\x9e\xdb*\xb2\xa8jaxm\x9eF7\x03\xb6\xad\xeb,\v\xe4\x03\xf6\"3\xbe(\xafU\xbaY\x12]i\xc0\x8fX\xe0G\xb50\xfc\x8d\xe6M\xcfN\x1e\xc7{\xc0\u00d8\xeb\x1c3\xb2\xae\x86<\xcbHi\xb7'\xb6\xf7\x8a\u028b\xf24~\x95\x19\x9e\xacA\x8b\xa3\xc3P\xec_\xb6\u007fo\x02_\xd3\xf5\xf41\xce\x03\xf0b\xd7!\xd6P\xcb\xe3x\x17\x00\xbfV\x01@FJ\x9aNl\xe7\xd1{&`\xfbw\xd2\xf8(x\xd8\f,\u05c7\xa8H\x19\x98\x19\xb34\xfa(\x88\xb3\\g\xe9\xa1\n\x80}\t\xec\v\x0f\x1f\xca\xd2\xf8OY\x12\x9d\a\xf0\xe7\x85U/\xf7}\xfff\xd7\x01K\xab\x82\xa7\xa1\xbcM\xbb\xdf6\x1a\xe3\u05fb\x0e!\xd2Oq\x1co^\x01\xf65\x14\xfb\xc2p\x14\n\u06e6\xbc\xbf\x92\xd2WfOw\x1dam\x06\x1e\x94\xa7\xd19\xaes\x94\t\x89\xf2mU\\\xc1\x9eP\x01@\x86\rI/\x8e\xe3Mjf\x9b\x13x\x92\alA\xe3\x0e\x00v\x04\xb1#\xd6\xf8e\u0505Sd\xfdj\x8d\xf0+\x9d4~\x1f\x81'\xba\xce\xd2\x0f\x06l\n\xe0\x95\x80\xbd\xd2c\x17Y\x12\xddG\xe2W0\\\xed\x19\u007fQm4\u007f\xa1-\u0566\U00051e43eC3\xcd\xfd\x97\xa1\xc0\xc9\xc9'\xe4\x9e\xf7\xa4\xa2\x82'y\xb0-Pp{\x98\xed\x00p'\x80O\x98z\x96nZ\x8c2\x92^\x9e\u01bb\xba\xce\xf1Xv \u0570.\xbf\xe9k\xb9\n\x00\xb2\xe8H\xd6\x10\xc7\x1bfUn\xe8\x15\u0730`eC\x000+\x02\x98\xf9\x00`\x05\x97\x02\xf0`V+\xcc\u01a6\x1fk\xc2P\x9fz.6\x00\x80\xa2@`\x86\u502d\x00\xb8\xb5\x82\u03b6\x84m\x05pk\x16\xb6\xb5\x19\x9e\n\xe2\x89y\x1a/\x83\aXa \f\x8fL\x19\xb2\x87\xafs\xb4Gz\xeb\xf6PIq\x8d\x0e\xfc\xc3\x0f=\xfc\x98.\x90\"\xfdP\r\xda\xff\xd1I\xfdS\x87u\x14\xc0\xe3\b\x00;\x00\x86\x03\x00\xc0\n O\xe34K\xa2\xdf\x02SE\x01tqM-\f\u007f;\xcc#\x05HZ\x9e\xc6;\xb8\xce1\x83\xa8\xea\x8f]\xee:\x84\f\x8f8\x8e7\xabY\xb1=\u0376\"lk\x0f\u070a\x05\xb6\x86a\xd3<\x8d7D\xd53+\x00\xa2X\xa3\r\xf2x\xed\x975\xef@\xd8T\x1bF7%d>\xba\u055d\xa0\xa9\xf62_\xc4v\x80\n\x00\xd2\x03Q\x14=\xb1Z\xc5^(\xb0\x17\x80\xbd\u0330\x17\xd8\u0740\x0f_\xdd\xec\x91k\x9f.x\"\x03\xc5l\xc5D;\x8e\xdfd\xc6s]g)\t\x1f\x98\xfa\xac\x03\x01x@\x9e\u019d,\x89n\x06\xecj\x18\xae6\xda\xd5U\u07ffjX\x8a\x02I\x92<\xa9j\xe5\\\x00\x90\xc0\xa5f\xd6v\x9dC\x06\x13W\xaf^\xdeiT\xf6\x06\xed\xe9 \xf6*\f{\x19\xb8)\xa7;\xf4\x06N\xf5\xeb\xd5v\x91\x92\xf0P<\x95zC\xca\xfc\x85\x9c\x98\xd8P\x05\x00\x993\x92~\xa7\x1d\xed_\xc0;\xcc\xc0\xc3@<\r\xc5\x1a\x9fF*L\x8a\f\x95F\x18\xfeO\x1eG\u07e7\xe1X\xd7YJ\xaa\n`G\x80;\x82x9\xc1\x87F\n\\K\xe0g\x1exQ\xd5o^nf\x89\xeb\xa0\xf3Q\xf3\x8a\xedX\xde\xc5R\xafv\x1d@\x06\a\xc9J\x1e\u01fb\x9b\xf10\x9a\x1d\x96\x03\a\x81\xd37\xc3L\xfd|)?\u00b6t\x1cA\x06\\^\xa9\x06\xecC\xd8;\xa7\n\x02\xf1\xe5\x06^\xc4\x02\xe7\u05db\u0341\xe9\xb8\x12\xb6\xb5\xeb\f3\xa2\xfd\xc6u\x04)7\x92\xb5v;~\xb6G\xbe4O\xe2#\xe1a\x99\xee\xa0\u02a0\xa2\xe1)z\xf7\xcaB\x10P\x01@fF\xd2\xeb\xa4\xe9\xbeD\xf1\xf2\xd9\x036\xd6-~\x19\x06\x06{\x8a\x86\xda\xcaBX\u015e\xacF\x9c<\x06\xc9z\x96\xb4N\xca\xd3\xe4F\xa2\xb8\x14\xc0k\x01\xaa\xf3/2\xe2|\xbf\xf9=\x03\xde\xef:\u01d0x2\xccN%\x8aK\xf34\xbe+K[\x1fm\xb7'\xb6w\x1dj]\xac\xbc\v@\xb6\x1a\x8d\xc6\x1d\xaeCHy\x90\xf4\xd24:6K\xa2+\xac\xf0\xae7\xf24\x00\x1b\xbb\xce%\xd2\v$\r\xe0\x93]\xe7\x90\xc1F`s\x15\x00\xe4a\xe4\xfd\xe3Y\x1a\xbf\xa9\x93\u01b7\x03\xf6\x15\x80\u06f9\xce$\"\xe5R\xf5\xc3\x0f\x01<\xc7u\x8e!\xb39ho\xb5\xa2rS\x96FWei\xf4Z\x92\x81\xebP\x8f\xb0\xcd]'\x98\xc1\xdde\x1e9!\x8bg\x8d\x1b\x177x\xc4\xf7\x00<\xc3u&\x91\x9e\x8b\xe3M\x00\x84\xaec\xc8`3\x16\x1a\x01 S\x17\xce\x97\x9e\xf06R\x01`\xc4\xe5I\xeb\xd0<\x8d\xaf\xa1\u067fB\x8b{\x89\xc8,\x98YR\xf3\xc3cI\xfc\xc0u\x96!\xb61\x81\xf7\xe7~\xe3\xeev\x1a\x9f\xc9U\xab6p\x96\xc4\xe0\xbe\b\xb1\x0e\x06\xdc\xe3:\x83\xb8\x93E\xd1\xd3\xf24\xbe\x14\xb0\xaf\xb0\xbc\xd3TDz\xc6*\\\xee:\x83\f\x03\x06*\x00\x8c\xa88\x8e7\xcb\xe3\u8ec4\xfd\x04\xc0N\xae\xf3\x88\xc8`1\xb3v=\b\x8f'\xf0c\xd7Y\x86\x1b\u01cd<-\xaf\xd7n\u03d3\xe8]$\x1b\x0eB,up\xce\xc7E\xe0^\xd7\x19d\xf1\x91\u007f]\x92%\xd1Y\xf0\xf0k\x00\xfb\xb9\xce#\xb2X\x8a\xc2sW\b\x96\xe1A\x84*\x00\x8c\xa0Q\xad\xcf\u01df?R\x05\x80\x11@\xb2\xd9I\xe2\xef\x82\xfc8\x80\xba\xeb<\"\xaeXQ\x94\xf7\xf3X\x06\x065\x05`4$I\xb2E^\xc1\xcf\x01<\xddu\x16\x11\x19^\xb50\xbc\xbc\xe6\xa7;\x03\xf8\x1c\x00m\xcf\xd6g\x046#\x8a\x8b\xa7Fv\xb1?\xd7\xf3\u056bK\xdb\xe0$=\x15\x00\x86\x1cW\xae\\\x96\xa7\xf1\xf94\x1c\xeb:\x8b\x88sV\u20ac\f\x0e\x83\x16\x01\x1cv\xed\xf6\xe4\x0eU\x14\xbf\x00l\x1b\xd7YDd\xf8\x99\xad\x98\xa8\a\xcd\xd7y\x86\xe7\x98\x16i[\fU\x03\xde\xd7i\xc7\xffC\xd2\xef\xf9\xd1\xcdJ9\xfc\u007fZ\xe1:\x80\xf4\x0f[\xad\x8d\xf3\xa0\xfeSh\xa1?\x91i\xa6~\x9b\xf4BCo\xa4!\x96E\u045eVx\x97\x12x\x92\xeb,\"2Z\xaa~\xf3\xc7U?\xddqz\x81\xc0\xd4u\x9eaG\xe2\xf9y\x9a\xfc\x90\xe4XO\x0f\xbcdIi\xd7u0+z_\xf0\x90R\x88\xe3x\xf3\xbc\x82KA\xec\xee:\x8bHi\x90\x1d\xd7\x11d8\xa8\x000\xa4\xd24\xdd\n\x1e~\b`\x85\xeb,\"2\x9a\xccVL4\xfc\xf0\x1d]x;\x00\xfc&4-\xa0\xcfxH\x9e\xc6\x17q\xf5\xea^\xee\x15\x9d\xf7\xf0X\xbde\x16\xb8\x8e \xbdG>\xb8\xb4\n\xfe@#\x17E\x1e\x8d\xf0J[\x90\x95\xc1\xa2\x02\xc0\x10\xe2\xc4\xc4\n\x8f\xdd\v\x00l\xec:\x8b\x88H\x10\x04w\u0583\xb1\x17\x83\xb6\x8f\x11\u07c7\n\x01\xfd\xb4w^\xaf\xfe\x80d\xaf:\xc7\xe5mp\x16*\x00\f\x1b\x92\xf5\xbb\x17\v\x03\x9a\x19\x01\xb4z\x90\xa9\xe7\xcc\xd8t\x9dAz+O\xe3\x8f\x03\xd8\xdfu\x0e\x91Rb\x89\v\xb22PT\x00\x182\x9d4~\x8f\x01\xcfv\x9dCDd&\x8d\xc6\xf8\xf5\xf5 <\xa9\vo\x1b\xc2\xce$\xf0g\u05d9\x86\r\x89\x17fi\xfc\xa1\x1e\x1d\xee\xc1\x1e\x1d\xa7\xa7Hl\xea:\x83\xf4N;\x8e_\f\xe0\xf5\xaes\x88\x94\x15\xa1\xadO\xa57T\x00\x18\"Y\x14\xedE\xe0t\xd79\x86L\x17\xc4u\xaeC\x88\f\xa3 \b\xeel\x04\xe1;\xeb~\xf8d\xd2^H\xe0\x02he\xf7\x9e1\xe0\x1dy\xd2:\xb4\a\a*e\x01\xc0z\x1e0A\xd2V\x01\xe8z^1\x01Vr\x16E\x8b\u0562]\xcf\xed\x014\x9b\u007fU\a_d\xf8M\x8f\x10\xb8l\xfa\v\xc0\xd4\xe2\xa8Y\xd6\xda\xd6\n\xdb\x13\u011e0\xdb\x13\xc0\xee\x18\x8e\xcf\xcc9+\xe0\xbd\r\xc0\x9c\v\x00y\xe1\xddT\xb5r\x0e\x01\x00\xb1\x9b\xeb\b2?Y\x92<\xcf\fOq\x9d\xa3\a\xba\x00\xee$p\x8b\x01\u007f\xa1\xd9} \x13\x8f|\xb8sNxmx|Lg\x9d\x85\xad\x06Px^\xb1\x12d\xc1\xc2[\x8d\x1a;\x9d\x8e\xf7@\x10\x04\x0f\x98\x99\xd6>\x91\xb5\u061d\x00U\x00\x90\x05Q\x01`\x80MW\xcf\au\xcf\xdc\u0508\v\xe8\xd9E\xf0\x8aKj\xb5\xe6\x8d\uaa0bH/M\u007f\xa6\xdc0\xfdu6\x00\x90\xacdYk;\x14\xb6\x87\xd1\xf6\x81\x87\xfd@\xec\x82\x11\x98>`\xc0\x91I\x92l\x19\x04\xc1\x9dsy]\x18\x86\u007f\xc8\xe2h\x15\fe\x9co\xffL\xd7\x01d~\xcc\xf0\x06\xd7\x19\xe6\xa9\v\xd8\u03cc\u0145\x80\xf7\xf3j\x10\\kf\xa9\xebP2*x\xa7\xeb\x04\x8fB\xac\xa2gg\xb9\x8e!s\xa3\x02\xc0\x00\ubd23\xc3\x00\xdb\xdcu\x8e91\\\r\xf2S5?\xf9\x96\xd9F\x93\xae\xe3\x88\xc8h1\xb3.\x80\x1b\xa7\xbf\xbe\x0e\x00\xe4\xfd\xe3\x9dv\xf0L\xd0\xf6-\x80}\r\xb6\x0f\xc0q\xa7A\xfb\xc3\xf3P\x9c\x02`\xee\u00ee\r7\x01\u0627\xe7\x89\x16ngF\u0466\xd3SBd@$I\xf2\x14\xa08\xc8u\x8e9\xba\x15f\x9f\xae\xe5\xddo\xd8\xf8\xf8\xfd\xae\xc3\xc8h\xb2\xa9\xd1&\xe5a\x18\xab7\x82\x0fj:\xca`Q\x01`\x80\x91\xf6b\xd7\x19f\xcd\xf0\x1b\x03O\xab\xf9c\x17\xb8\x8e\"\"\xb2\xa6\xe9b\xe4\x85\xd3_ Y\uf92d\xfdi\x95\xa3A<\a\xe0\xf6n\x13\xf6\x8e\x01'\x91|\x8f\u065c\xc7\xf4_\x87r\x16\x00,\xf3x(\x80\xaf\xb9\x0e\"\xb3WEq\"Q\u0485%\x1e\xebN\x82\xa7\xd7\xfd\xe6\u007f\x99Y\xe1:\x8c\x8c\xb6\x82v\xd3\xdc?\xbe\xfb\xaa\xdaI\u04e7\x01\xf8\x85\xeb 2{\xda\x06p@\x91\xac\x01x\xbe\xeb\x1c\xb3\x90\x19\xf8\xceZ#|\xba:\xff\"2\b\xcc,\xab\x05\xe3\x17\xd7\xfd\xf0m\xf5 \u0721\xb0\xcaV0{#\xc0\x9f\x03%]\x0f\u007f\xf66\u03f2h\x97\xb9\xbf\x8cW\xf6>Jo\x18\xecP\xd7\x19dnh8\xdeu\x86Y \x88O\xd7\xfcp\xa7F0\xf6uu\xfe\xa5\f\xe8u~\xeb:\xc3c\xb0\xfb\f\xd7\x11dnT\x00\x18P\x9d4\xdd\x17\xc0\x12\xd79\x1e\xc7_\r\xde\x11\xb5`\xec\xcc\xe9a\xb7\"\"\x03\xc7\xf7\xfd\xdb\xeb~\xf8\xe9z0v`a\x95\xedi\xf6a\x00\xf7\xb9\xce5_^\x17G\xcd\xf55\xf4\xca[\x00\x00\xf0\x9c\u98b8\f\x00\xb6Z\x9b\f\xc0\xe2\x8dm\x82/\xaf\x87\xcd7jh\xb3\x94I\xa31~+\x80\xc4u\x8eG\xf1l?\xd7\x11dnT\x00\x18P\x05\x8a\xc3]gx\x1c\xf7\x15\xd6\u076f\x16\x04\x97\xb8\x0e\xd2\x03\x9a*#\"\x00\x00\xdf\xf7on\xf8\xe1;j~\xb89i'\x00\xb8\xcau\xa6\xb9\xa2y\u03de\xebk\xea\xf5\xb1\u07c3X\u054f<=\xb0Q\xb7\x1d\x1f\xe1:\x84\xccN^\xc1\xe1(\xf7\xf0\xff\xd4P<\xbb\x11\x8c}\xddu\x90\x1eP\xfbe\u0218Y\x17\x86\x1b]\xe7X\x13\x89\xc3I6\\\xe7\x90\xd9S\x01`@\x19x\x80\xeb\f\ub462\xc0\u047e\xbf\xe4\xf7\xae\x83\xf4Ba6\x8c\x8b\x81\x89\xc8\x02\x98Y\xd6\b\xc3sk~\xb87=\x1c\a\xe0V\u05d9f\x8f{\x91\x9c\u04ee\afV\x18pi\xbf\x12-TA\xbe\xd4u\x06\x99-+s\xfb\x85\xa4\xbd\xac\x16\x8c\xff\xccu\x90^0\x1b\xca\xc5L\x85\xb8\xc6u\x84\xb5\x8cu\xda\u0473\\\x87\x90\xd9S\x01`\x00M5\xdclO\xd79fd\xf6\xf6z\xb3y\xb5\xeb\x18\xbdbd\u0667Z\x88\x88#f\xc6F\xa3\xf9\u075a\x1f\xeeJ\u0619\x00\x06a\x9ep3\xcbZ\xdb\xce\xf5E\xf4\xec\xc2~\x84\xe9\r{\x1ey\xbf:;\x83\x80\xd8\xdbu\x84\x19\x11\xff\xde\b\xc3\xffu\x1d\xa3W\x8c\xa6\xf6\xcbP\xe2e\xae\x13\xac\x8d\x05\x8eq\x9dAfO\x05\x80\x01\x94e\xad\x1d\x004]\xe7X'\u00f5\xb5F\xf0\x19\xd71z\u0248M]g\x10\x91r3\xb3\xa4\x11\x84\xef\xf4\f\xc7\x02X\xed:\xcf\xe3*l\x8f\xb9\xbe\x84\u05bd\xa8\x1fQz$\xcc\xdb\xcdW\xb9\x0e!\xebG2\x80a'\xd79f\xf0\x97Z\x90\xbe\xdbu\x88^*<\xb5_\x86Qa\xd5\xd2\x15\x00`\xf6\"\xad\xc528T\x00\x18D\xddJi\xb7\xa4\xf2\x80\xf7\x0e\xdbJ\xb94\xec\xe0:\x83\x88\f\x86\xaa\xdf\xfc\x11hG\x02\x98p\x9de\xfdl\u7e7e\xa2\xd1\x18\xbf\x11\xc0=}\b\xd3\x1b\xe4[Hj\xces\x89eY\xb45J:/\xdd\xc8\u007f5[Q\xf2\xdf\xdb9\"\xd5~\x19B\xbe\xef\xdfJ\xe0O\xaes\xace\xe3,\x8b\x8fv\x1dBfG\x05\x80\x01\xe4Y\xb1\x8d\xeb\f3\xb8\xa3\xd2\b\u007f\xe4:D/\x91\xf4\x01<\xc5u\x0e\x11\x19\x1c\xf50\xbc\xb20\xbc\f%\xde2\xd0`O\x9a\xd7\v\xc9o\xf78J/m\x91\xa5\u044b\\\x87\x90\xf5\xe8zem\xbf$\u056c\xf3E\xd7!z\u0360\x1b\x18\xc3\xca3\xfc\xc2u\x86\xb5y]\xbc\xdau\x06\x99\x9dQ/\x00\x94y\x15\xda\x19\x11V\xce\x0e\xa9\xf1;\xc3v\xf7\xbf\x93\xa6{\x03\x98\xd3bY\"\"\xbe\xdf\xfc>\x80\xaf\xb8\u03b1\x1e\x9b\xcd\xe7Ef\xa5.\x00\xc0`\xa7\xcdu\x81\xc3aA\xde?\xdeN\xe3\u007f\x89\xa2\u826e\xb3\xcc\xc4C\xf1T\xd7\x19\u0585\xc0Om\u0672\x95\xaes\xf4\x12[\xadM\xa0\x1b\x18C\x8b\xc4y\xae3\xac\x8d\x86\xa3\xd3tb;\xd79\xe4\xf1\x8dv\x01\xc0\xac\x9c\xf3\xe8\x1f\x87\x11\x1b\xbb\u03b0.\x1e\xecb\xd7\x19z\xad@\xa1\xad\xa5Dz\x88d3K\xe3S99\xf9\x04\xd7Y\xfa\xad\xb0\u0287\x00t]\xe7\x98\xc1\xbc\n\x00U\u007f\xecR\x00\xf7\xf78K/\ud4b7\xe3S\\\x87p!K\xc3w\x18\xf9\xf6\x9a\x87\u06f2\xb4\xf51\xb6Z\xa5k+\x14\x9eW\xca\xdf{\x8fV\xe6\xf5-\xe6%\xaf\xe0\b\f\xe8\x8d.y|\xb5.\u007f\x84\xf2\x8d2\xabx\xac\xbc\xd3u\x88\xb2H\xd3t\x1b\x92\xa5\xeck\x972\xd4b\x19\xd4\xd5\xddi(\xe5\x054\xa7w\xbd\xeb\f\xbdf\x869\xef\x97-\"\x8f\u0155+\x97\xe5Itz\x9e\xc6w\x80\xfcD^\xf5>\xe8:S\xbf\xf9\xbe\u007f\x1b\x80+\\\xe7X7n8\x9fW\x99Y\x17\xc49\xbdN\xd3S\xc4?qbb\x85\xeb\x18\x8b)\x8e\xe3\xcd\rx\xf3\xf4_}\xd0\u079cW\xec\xf6,n}b\xfaNp)\x18Y\xca\xf6\v\xbc\xe2\x06\xd7\x11z\xcf;\xd2u\x02\xe9\x1f\x1b\x1b\xfb3\x802\xee\xb8\xf5\xd24MK9\xd2g11\x8a6\xf5\u063d\xefW\u04fe\u043b(}\xe1\x01\xdeW\x87}*\x00W\xad\u0680(\xbe\x8a\xd9\xcd\xf5^b\xc0{\xf3\xa0~{\x9eD\xa7\x93\xf7/\xfa(H\x83\x95\xb1\xfd2THz\xf8\xff\xed\xddi\xa0\x1cU\x9d6\xf0\xe7\xa9\xee\xda\xfb\x86\x84}\x13P0\b\x01\x14Y\a\xd9\x15\xc4u@\x05A\x11ePTF\x84\xc1\x8dyQ\x84\x81\xd7\x11\xb7\x17Gp\xc1\x19\x10\x18eXD\xd4q\x14Y\x05q\x04A!a\x93\xb0G\t\b![wUuUw=\xef\x87{\xc1\x80I\xb8Kw\x9f\xea\x9b\xf3\xfb\x06t\x9f\xf3\u0710\xdb}\xea,\xff#|\xcct\x0e\xab\xff\x82 \x98\x0f\xe0v\xd39V\xa6\x94\u039e\xee\x13\xfc+3\xba{X_\\\xc5\u007f\xdeJp\xae\u0353\xd69&>\u007fW4\xa8#\x00\xed\x01\xf53!\x12\x86\xb2R%\xabytcZ\xfd\x92\xe7\x99\xffITt\xab\xa2eUQ\x9e$\xbb\x16Y\xebG,\x9d\xb9\x80\u0787\xf1\xde\xf5M\x1c\xdfN\x9bG\xf67\x9d9\x14\xaa\xfc\x00:\xe9\xc9y\xb7\xdd\xfe\x1e\x80f\xef\xa2\xf4\x9e\x80M:^\xed\xbb\x93\xdd\xe90\f:\x81{\x1e\x80\x97M\xe8M\xc2,\x01g\x14Y\xf4H\x916O\x964\xc0\x82\xc8e\x15\xc7/p\x1cg\u068ca\x8a\xac\xf5^\x00sL\xe7\xb0\x06\x83\xe2%\xa63\xac\x1cg\x17Y\xf25\xd3)\x06IZ4\x83\xa5s\x19\x80`5/s@\xfcc\x9eE\u007fl'\xc9!\x83\xca\xf6\xb7!\x06\xa3\xaa+ [Kj\x98\x0e1\t-\xd3\x01V\xa26]VZ\xd4ln\xb8B1%\u02f2V\xa3H\x9b\xaf\xcf\xd3\xe4ZP\xb7J8\x18\x93\xf8^!x^\x91\xa6{\xf5!\x9eq\"\xb77\x9da\x15\x12\x92\xc5d\xdf\xccY\xb3\x96@\xb8\xa8\x97\x81\xfaA\xc2!y\x96\x9cn:G?\xe4i\xebC\x12\xde5\x85&\xd6\x11\xf8\xafE\x96\xd2N\x92CM\x87\x18\x04I\xf5\"\v\xae\x00\xb0\xedx^O`#RW\xe6i\xf3\x12\x13\xb7\"\rd\x02\x80\u055d\x00\xa8w\u06ad\xddM\x87\x98\x84J~\x81\x16\xb5\u06b4\xb8o\xb6S\xe79\x00\x86qb\u0232\x06B\x12\u06ed\xd6\xc1y\u06baU\u0d40^?\xc5&C\xa9\xfcI\x9e7_\u0753\x80\x15!\xc9!0\xd5?\x9b\xbe \xb0d\xaam\xa8Go\xb3\x12\x00\x00 \x00IDAT\xd6\xfd\x06*z\xc4oE\x04>\x97g\xad\x0f\x9b\xce\xd1Ky\xab\xb5\v\x80\xb3{\xd4\u073a\"\xbf\x98g\u0243y\x96|\\\xd2\xeaV\xaf\xa6\xc4a5\xc7/%\xcbi1~\u0273\xe4T\x00k|\x05\xf65\t\xe3x!\xa0\x1f\x99\u03b1*\xa4\xfe\xbd\xddn\xee`:G?Ib\x91\xa5\xe7\x038`\xe2\xef\xe6\xe1E\u07797O\x9bG\xf5<\xd8j\fd\x02@\u055d\x00\x80\x80\xb7\x98\xce0\t\x95\xfc\x02\x85\x83\xa1\x1f\xbc\xb7\x93\xe4p\t\xef4\x9d\u00f2\xaaHR=O\x9b\xef+\xb2d\x1e\x1d\xfc\b\xc0\xae=k\x9c\x98\x89.\xaf\x9bN7\x03t\u06ad\x03\x05lb:\xc7\xca\bX:\xd56|\u007f\xc6\xfd$\xae\xeaE\x9e\xbe\x13\xcem'\xc9;L\xc7\xe8\x85,\xcb^\x01\a\xff\x8d\x1e\x17y#\xb0\x11\xa4\xafw\xb2d~\x9e\xb5>*i\xf25\"VA\xaa\xe8\xf8\x85\xce\xd0?\xa0\xe4\xad\u058e\x04>e:\x875xD\xed\\\xd3\x19Vc\x86S\xf2\xe7i\x9ana:H?Hb\x91&_\x1f;\xfa8Y\xeb\x00\xbc\xb0\x9d\xb6\xaeN\xd3t \x93\x91\x03:\x02\xa0\x85\x83\xe9g\xe2(\xbeKR\xcdt\x8e\t\xaa\xea\xb9\xcb=L\a\x98\x8a,\xcb^IG\xdf4\x9d\u00f2\xaaFR\x90g\xad\x8f\x16Y\xf2\x00\xc0\x8b\u043f\xf3\xa5\xeb\xc0\xc1uE\u059c\xc4,z\xb5H\xa2\xc4\n_E\xa7\xa7{\xd2JGg`8\x8a\xc0\xd6H]\x96\xa7\xad\x0f\x99\x0e2\x15i\x9an\xe6\xa8{\r\x80\xbem\x19\x15\xb0)\x84o\x16\xa3;\x02N\xe8\xe9\xd5U\xaa\xe6\xf8\x85\xd2\xebLg\x98\n-]\xba6\x1c]\n\xc05\x9d\xc5\x1a<7\fo\x020\xcft\x8eU\x11\xb0q\r\u076b\x93$\xa9\xe4\x84\xf8dIr\x8a,\xf96\x88\xe3{\xd1\x1e\x81\x03k(\xe7\xe5I\xf2O\xfd~6\x1dT\r\x80\xaa^\x83\x04\x01\x9b\xe6i:T+\xbed5\xbf@\x01\xbce\b'S\x00\x00Z\xbcx\xa6\xa3\xf2'\xb6\xf0\x9fe\xfd\x95\xa4F\x9e%'t\xb2\xe4A\b\xdf\x040\x88\x99\xe9\x19\x12\u007f^$\xcd\xcf\f\xa0\xaf\xbe)\xda\xc9q\x00\xfe\xcet\x8eU\xe3=\xbdh\xc5k4\xee\xa4*y\x17\xf5\xca\xd4\x00|\xa7H\x9b\xffl:\xc8ddY6\xbb\x86\xf2&\fn\x8b\xf7\xcb \x9d]d\u027dy\xda\xfc\x80\xa4\xf1\x15\xf6\\\r9\xac\xea\xf8ew5\x9b\x1b\x98\x0e1\x19\x92\xea\x85\xe7^\x06\U000159b3X&\xa9\xe2\x05\xf78\xbbN\u0752e\u02c6\xb2\x00\xfb\x8bI\x8a:\xed\xe4R\x00\xc7\xf6\xb8\xe9\x18\xd4\u05ca,\xf9\xdf~\x1e\x9d\x18\xcc\x11\x00:\u007f\x1aD?\x93E\xeas\xbd\xf8b\x1b\x14\x95X`:\xc3*l\xd0m'\a\x9a\x0e1Q\x92\xe2\"\xf0\u007f\x04\xe8U\xa6\xb3XV\x15h\u0672u\xdbi\xeb\x8c\"M\x16@:\xdb\xc06\xf6\x9a\xc8/\x16Y\xebJ-[\xb6\xee\x80\xfb\x9e\xb2\"I^\a\xe1\u02e6s\xac\x16qw\xaf\x9aR]\x9f\xc3\x10\xd4\x02\x18C\x81_\xc8\xd3\xd6\xf9\xc3T\x04\xb8H\xd3}\x1cu\xff\x17\xc0\xe6\x06\xba\u007f\x05\xc0\v\x8a\xacuO;m\x1e)i\xd2cGBU\x1d\xbf\xd4\n\xc79\xc2t\x88\x89\x92T+\xb2\xe4?zP\x87\xc5\x1arn\x10\u007f\x1f\xc0#\xa6s\xbc\x84\xcd\x1d\xd5n.\xd2tO\xd3A\xa6\"I\x92M\x8bvr\xd3\x14\x8b\xb0\xbe\x94]X\xf2\xf6v\xda:\xb3\x1fuY\x06S\x04\xb0\xac\xee\x0e\x801\xdbu\xd2\xd6I\xa6C\x8c\x9b\xc3\aMGX\x95R\x1c\xaa\xf3g\x92\xe2\"K\u007f\nh_\xd3Y,\u02f4$I^\x96'\u0373\v\xb7\xf6(\x81\u03c2\x98i2\x8f\x84Cr\xb76\xb7\x9d$C\xb3K+O\x92\xddE\xfd\x04\x15\xbf\x1a\x95rz\xb6]\xd4\xf3\x1aw\x01\u057f\x11\xe0E\x8e.\xb2dn\x91$\x95>\xba&\x89y\x96\x9c \x94\xbf\x04\xb0\xb6\xd94\x9cM\xf0\xe2\"K\xee\xce\xd3\xe6Q\x93\xd9\xf1G\xa8\xb2\xe3\x17P'\xf5\xa3\xeeA\xbf\x8c=\xfc\x9f\x0f`\xa0\xc5\u00ecj\"Y\x808\xcbt\x8eqXO(oh\xa7\xad\u04e62\x99hJ\xbb\xd5:\xb8N\xdd\ta\xa7\x01t\xe7\x128\xa5\u0212\xbb\x8bt\xf9\xfe\xbdlxPE\x00+\xbd\x03\x00\x00D\x9eY\xa4\xe9\u07a6s\x8c\x87\x03\xcd7\x9da\u0574_\x96\xb5\xdef:\xc5x$I\xb2I\x91%\xd7\x01\xda\xcft\x16\xcb2)\u02f2\xd9y\xda\xfa\x8f:\xf5 \xc8\x13\x00\f\xf0^\xf0\xd5\x1b\xbb*\xe7\x8av\xda\xfaE\xbb\xbd|\x1b\xd3yV\xa7\x9d6\x8f\x04u=\x8c?\xa8\xbd\xa4\xb2\x9ee==/\xda\x11?\a \xe9e\x9b\x03\xf0rQ7\xb6\xd3\xd6\xe9\x92*\xf3w\xfe9Y\x96\xbd\"\u03d2_@:\x1b@\x95\x1eL\xb7\x01xa\x91%\u007fh'\xc9;$q\xbco\xec\xa2^\xe1\xf1\v^\xd6I[\x1f7\x1db<\xa4\xa7G:\xed\xe4\n\u0607\u007fk\x05\xae\x1f}\x0f\xc0\xe3\xa6s\x8cC\x9d\xc0\xe7\x8b,\xb9:\u02f2\xadL\x87\x19\x0f-[\xb6n\x9e\xb6.\x18+\x80<\xe8k\u03f7\x14\x9ck\xf3\xb4\xf5-\xe9\u0675z\xd1\xe0`f^j\u075e\x9c5\xec3W(\u007fR\xf5\xd5\x00\x00\xe8\xa0V\xe5/P8\xc29Z\xb6l\u043f\x1c\x13R\xa4\xe9\x9e5\xeav\x00\xbb\x99\xcebY\xa6\xe4\xad\u058ey\u06bc\xd4Q\xf7>\x00\xff\x80j=d\xbc\x00\x817\xb2t\xe6\xe5irQ\x96e\xb3M\xe7YQ\xab\xd5\xda8O\x9b? x1*\xbe\xf2?\xe66\u039a5\xe5k\x00W\x14E\u045f\x88\xa1X}z1\x97\xc0\xa9y\x96\xcc\xcf\xd3\u0587\xaaP\xc7FR\xa3H[\xa7:\xea\xdeM\xa0\xca\xc7\xea\xb6'\xf5\u00e2\x9d\xdc\xd1\xc9Z\xe3\xbaQ\xc9\xf7\xfd\xc7\x00\xe4}\xce5i\"O\xab\xfa\x19\xe5,\xcbf\x17Yt\xab\x84\x83Mg\xb1\xaa\x85d[\xd0)\xa6sL\xc0\x1b\x1cu\xe7\x8d\xed\x06\xa8\xe4w\xa7$/\xcfZ\x1f)\xdc\xda\xfd\x00>`0\n\x01|\xa4\x93\xf9\xf7\xb4\u06ed\xbf\x9fjc\x03\x99\x00\xf0\xbc\xc6\x03\xa8\xea\xd5u/\xb4\x96\xa8\xeb\xf2,9\xc1t\x90\xd5\xf1}\xffQ\x00\x85\xe1\x18\xab\xb3Y\xe1\xd6/\xefi\xe5\xe0\x1e\x91\xe4\xb7\xd3\u0599By=\x81\rM\xe7\xb1,\x13\xb2,{e;m\xfd\x0f\x1c\xdc\x01\xf00\f\xae \xecT\xd5\x00\xbd\xcfQ\xf7\xbev\xda\xfa\xef\"k\xbe\xd1\xe4\x16B-_\xbe~;m\x9d\xe1:\xf8#\xc0\xa19?L\xf4\xa7h_=\x88\xce\x02\xf4@?\xda\xee7\x02\x1b\x018\xaf\u0212yy\xd6\xfa\x88\xf4\xf4\u02203h\xf1\xe2\x99E\xd2\xfcd\x91%\x0f\v8\x1d\xc31\x99\x04\b;v5\xbe[\x80Hv\x01>\xdc\xefHS\x10;r~\xa2\xa5K+\xb7\x8bg\xf48H\xeb8G\xe5\xed\x00*\xbd\x1b\xca2\xc7\x1b\xad\x05\xf0;\xd39& \xf0\xf9\x8f\xc0F\"\xbfRd\u0263y\xd6\xfcj\xbb\xdd\xdc\xdeD\x8ev{\xf96\xed\xb4\xf5\x85\"K\x16\x00\xf8.\x06s\xfb\u0444I8\xb4\xa8\xf3\xde\xe3\xbbJ{@\u0623N!O[\x0f\x93\xb8\xb3\x14\xee'\xb9P%\x9e\xaa9J\x04\xb5\x01\xa0$ct\u9065O:kK\u0690\xc0\xc6\x02\xd6'\xb4U\x91%[\x82pz1'W\x83n(\x81a\xa8\xb8\xbf\rK\xde\xd6N[\xff\xcf\v\x92\u007f%\xd7[>\x88N%\xd5\xf2$y\x1b\x1c|\x94%\x0e\xd0\xf0\xaetZV_\x94\xac}\xc5Q\xf7\x83\x18\xbd\x1b}\xbaX\a\xe0\xe1\x90\x0e\x87\x03\x14YR\xe4i\xf3\x11\x8a\u007f\x14\xf4\b\u027f\bX(qy\xcdQ*(\x03\x00\x82n\xb7d\x83,c\n\xeb\xc9q6 \xb4\x85\x80-\x8b,\x99M0\x1am~\xf8?F\b\\\xd0\xef>\xdc ;\xa1\u0202\xfd`\xe6\u02ba~Xo\xec\x88\xc7\x115\x94(\xb2\xa4\x99g\xad?B\xbc\x8f\xd0\x03\"\x97@eSr\x9a\x8eS.\x06\xfe:\x1e\xa0\xa3\xf5$l\xc0\u046b4\xb7\x86\xb0\x1dP\xce|\xee\xef\xd2\xf0\xff\x8d\xc2\x05n82\xa1\a\xfa\x92\xb5\x1b\x1cu\xfb\x95\xa7\x976\x90\xf8\x8b{l\xa7\xf8j\r\xecGM\xd3t\xb3\x1a\xca\xc7\x06\xd5\xdf\xd0)\xb1\x8b\x17\u01f7\x8f\xf7\xe5\x92\x1aE\x96<\x8b\xe1\xfabX*\xf2\xbb`\xf7|\xdf\x1f\xb9\xaf\u05cdK\xaaw\xb2\xe6\x9e\"\xdfF\xf10\x01\x9b\xf6\xba\x8f\x8a\xb8\xd6\v\u3fafJ\x14i\xeb\x14\x01g\xf6\xbb\x9fIx\xd6\v\u305d\xc5R\xab\xb5Q\xe1\xe0\x89A\xf57\x11rp\xb0\xef\xc7?\x9e\xec\xfb\xf3\xb4u!l\x15\xe95I\xdb\xedjs6\x1aO\xf5\xbb\xa3\"]\xbe\x9f\xe0\\\x8b\xe1\xa9/aM\x10\x81?\xd7\xf3\xce\x0e\\k\xadg'\xfa\xdep\xb0\xf1\u8a9c\x1d\xc1X\xbd\u11cd\x1f\x14Y\xeb\x9d\x12\xdea:K\xffh\x04\xc0\xb6\xa3\xbf6+_\xd7\x1e\u0685\xfd\x89\x12\xe6\xbaA\xf8\x9d\xf1\xbct\x90G\x00\x00\xf2\u007f \xd9\t\x80^!/\x854L\x13\x00+\x9a\x03q\x0e\x85\x93F\x8fW$\xcbA=$\xe1\t\x02OCh\xcaas\ucd644\x13\x80Oam\x01\x9b\x83\u063c\x00\xfez\x17\xa6\xfd\xbe\xb4\xac\tc\xa3\xf1T\x9e%\x9f\x87\xf4u\xd3Y\xac\x01(q\xee \xbb\xf3\xfc\xf0\xb3E\xd6\xfa;{vy\xfa\x11\xf95/\x88\xaf\x9b\xec\xfbI]*q\x98&\x00V\xb49\x88\xe3\b\x1eGuQdI\x96\xa7\xad\a\x05\xf7bJk\x01\xf0I\u0312\xb0)\x80-\x8a,Y\xf7\xafO%\xb4C\x18\xabo\xeai~L\x11x;a\xb8v\xdcX\x13W@\xf8\a\x92\xed\xf1\xbcx\xa0\x13\x00T\xf9s\x81U\u071e3\x94\u073c\xf3\xfd\u00ad}\t@\xe5\xae\u06db8\x8d@x\r\x81\xd7\x00\x00\bP\u007f\xfb\x958\xb5\xd2K\x96e\xbd\x98\xeb\x87\xe7\x16\xed\xe4\xfd\x10^k:\x8b\xd5?\x14~\xea\xc6\xf1@\xaf\x86\"\xd9Q\xb3yX^\xc3\x1f\u01ae\u0673\xa6\x87\xbb=?\xfc\xdcT\x1a\xa8\xfb\xf15cU\xb6\xfb\xb2\x1d|\xc0\x02\x00\xdb\x11\xd8\xee\xb9\u0548\x95\x8e_\xecS\xbee\x00g\xcdZR\xa4\xe9QBy=\xa6W\xcd\x1fk\x05\x04Nw\xe3x\xdc\xf5\xf6\x06z6\xaf\x1e\xc47\x03xz\x90}Ng\x9c1c\x11\u065f\xfb\x9c-\xcbZ3\x90\uc5a8\x1d1z\x8e\u039a\xa6\xf2\xaeS\xfb\xa4\x89\x8e\xd9h<\xe5\xc09\f\xc0\xb8V%\xac\xcak\xca)\x0f\x9bX\xd5\xff\xbfE\xb2\x14\xf0\x9f\xbd\neY\u05aa\xb9ax\x13\xa1\u03da\xcea\xf5\xcd5\xf5 \xfa\xe2D\xde0\xd0\t\x00\x92\xb9\xc8iq\xf5TUt\xa7\xc9U^\xd6Dh\xfa\x171\xb1\x06*\b\x82\a$|\xd8t\x0e\xab?D\x9e\x1d\x04\xc1\x03\xa6\xfaw\xc3\xf0\u05c0\x8e5\u057f\xd53\x92xL\xaf\x8a\xf8\x8a\xb5\v`\x0f\xf0\xadQ\xb4&\x14a\xab\xa8z\x10\x9f\x05\xe8\x12\xd39\xac\x9e{\xdc-\xba\xef!9\xa1\xabU\x06^\x9dWp\xbe\r\xfb\x81\xdf3\xbe\x1f\xfd\x1c\xc0C\xa6sX\x03D\x0e\xe3]\xd1V\xc5\xf9Qt\t\xa6\xc5\xfd\xed\u058b<\xe5\xf9\xe9\xff5\x1d\xc2\v\x1b\x17\t\xf8\x82\xe9\x1c\xd6\xe4\t\xf8W?\x8a.\xebU{A\x10\xcc\x17pM\xaf\u06b3\x86Bd:\xc0\x9a\x8a\xa4\xdc \xfe \x88\u07db\xceb\xf5L\x86\x12\x87p\u018cg&\xfa\u0181O\x00\x04A\xf0\x10\xc0\x1b\a\xdd\xeft5z\u05e3\xfe\xc5t\x0ek\x80d\xbf@\xad\xfep\x83\xe8\x1f\x01\f\xf4\x9c\xb8\xd5Wr\x88\x0f\x92\xeb,3\x1d\x04\x00\xbc \xfa,\x80\xf3L\xe7\xb0&N\xc0/\xbd :\xb5\xd7\xed\xb2\xc4)\xb0\x8bBk\x92H\xb2\u055cL!\x99\xb8]\xbc\x15\xc0\xa3\xa6\xb3XS&A\x1f\xf4\xe2xR\x13:F\xee\xe7\x95\x06[\x89x\xbas\x83\xf8\xfb\x00z\xb2%\xcf\x1a\nv\a\x80\xd5\x17$\x13\xb7\xe8\xbe\x19\xe0\xfd\xa6\xb3X=@\x9d]\x0f\xe2\xca\u0509\x19]\x81\x8a\x8e#q\xb9\xe9,\u0584\xdc\xe3e\xf9\xbb'\xba\xc5t<\xbc8\xbe\x9d\xb2\xb5\x8c\xd6 \x0eF\x8b&Z\x860\x8e\x17\x96\xac\xbd\x01\xc0@\xae\x84\xb5\xfa\x83\u04a7\xfc\xb0\xf1\xfd\u027e\xdf\xc8\x04\x80\x17\x86W\x82\x18w\xa5Bk\xf5Hv%\x9ef:\x8750v\a\x80\xd57\x9c1\u364ep \x80\x05\xa6\xb3XS@\xdc\xe1\xfa\xf1?\x9b\x8e\xf1b$\xbbu?:R\xc0\xcfLg\xb1\xc6\xe5\xf1\x8ex\x10g\xcdZ\u04af\x0e\u029aN\x01P\xf6\xab}\xabb\x96/\xb7\x8b\x18\x86\x05A\xf0\x10J\xbc\x15\xc0\u0497|\xb1U9\"\xbf\xe4F\x8d\xafN\xa5\r#\x13\x00$E\xe8\x14\x13}OW^\x18^\x0e\xe07\xa6sX\x03\xd10\x1d\xc0\x9a\u07a2(Z Go\x050\xe1seV\x05\bKJ\u050e\x18\xef}\xc0\x83F2\xf7\x82\xe8\x1d$\xae4\x9d\xc5Z\xadEr\u0283\xa2(\xfaS?;\xf1\xfd\xc6<\x00\x17\xf5\xb3\x0f\xab:2\u05f5c\x98\n\xf0\xe2\xf8v\x88\aB\xe8\xdb\xe4\x9e\xd5\x17\xe7{~x\xf2T\x1b12\x01\x00\x00n\u0438\x1a\xe0u\xa6\xfa\x9fnH\xaad\xed}\x00\x9a\xa6\xb3X}\x17h\xf1\u2666CX\u04db\xef7\xe6\xca\xe9\xee\x05\xbb\x13`\u0624\xa4\xf3\xf7A\x10\xcc7\x1dduH\xe6u?z7\xa0KMg\xb1V*\xa1\xf8\xb6^U\xfc\u007f)n\xd0>\x11\xc0\xe3\x83\xe8\xcb2\xab&mh:\x835\u028b\xa2\xdb \xec\x0f`\x91\xe9,\u05b8|\xc7\r\xa2\x0f\x91\x9cr\xdd\x14c\x13\x00\x00\x80R\x9f\x06\xd01\x9aa\x1a\t\x82\xe0a\x90\xff\xc7t\x0e\xab\xff\U000a8fa9\xe9\f\xd6\xf4\xe7\xfb3\xee\xef\xc2\xd9\v\x90\xb1+\xe4\xac\t\xe9J<\xd2\r\u00dbL\a\x19\x0f\x92\x1d7\x88\x8f\x10\xf9%\xd3Y\xac\x17h\x11\xe5\xdb\xdc(\xfa\xdfAuH\xae\xbd\x94\xd41\xb0\x05\x01\xa7=\x01v\xfcR!^\x1c\xffAN\xb9\x0f\xecd\u007f\xb5\t\xe7\xbaA\xf4Q\x92=9.et\x02\xc0\x8b\xe3\xdf\v0~=\xd1t\xe2\xfa\xe19\x02~i:\x87\xd5_\x8e\xb8\x89\xe9\f\u059a!\f\xc3\xc7\xdc.\xf6\x06p\xbb\xe9,\xd6j\t\xd0\xd1~\x14\r\u0576z\x92\xf2\x83\xe83 O\x84=\a^\x05K)\x1e\xe0\x86#\xd7\x0f\xbac7h\\\v\u0ec3\xee\xd7\x1a,:\xb0\u35ca\xf1\xfd\x91{:\xe2\xeb\x00\xdcm:\x8b\xf57$\xe0t/\x8a?\u058b\x95\xff\xe7\x98\xdd\x01\x00\xc0\v\xa23\x01\xdcj:\xc7tAR^\x89\x0f\x00x\xcct\x96i\xe4\x1a@\x95\xdaN+\xd1\u03a0[\x03\xc3F\xe3)7\x88\xf6\x02\xf0=\xd3Y\xac\x95\xea\x008\xd6\v\x1b\x17\x9b\x0e2Y^\x10}]\xe2\xa1\x00\x97\x9b\u03b2\x06[\x84\x12\xfb\x0fr\xe5\xff\xc5\xdc :\x11\xc0oM\xf5?\r=\b\xf0\x06\xd3!V$\xd8\t\x80*\x8a\xa2h\x81\x9b\xe5{\xd9E\xc4J\xc9\x04\x1d\xe9\x87\xf1i\xbdn\xd8\xf8\x04\x00\u024e\x9c\xee\a\x00\xa4\xa6\xb3L\x17\x8c\xe3\x85r\xca7\xdb\xc2\x1e= ,\xe9\x88\u01d0\xbc\xc7t\x94\x15\xd1~\x81Z\x03F2\xf3\xc2\xf8h\x10\x1f\x06\x90\x9b\xcec=\xaf\xe5\x10\x87xa\xfc\uf983L\x95\x1fEW\xca\xe9\xeen\x8f\x9c\x18\xf1\x90\x9cr\xaf\xc9\xde)\xdd+$S\xb7\xe8\xbe\r\xc0\x83&sL\x13\x1d\x88\xef\x15q\x9b\xe9 +\xa2J\xbb\x80QQ\x9c5k\x89\x17D\aQ:\x19\xf68\x8eQ\x02\x16B\xdc\xd7\x0f\x1b?\xe8G\xfb\xc6'\x00\x80\xd1s\xa6%q8\x80\x9e\xdf1\xbb\xa6\xf2\xfd\x91{\xc9\xf2\x10\u0601\xfaT\x94\x8e\x83#\xa3(ZP\n\x03)\x844^v\x06\xdd2\xc5\v\xe2\xf3(\xee\x0f\xe0Q\xd3Y,<\x85\x12\xfb\u0583x\xda\u0723\xee\xfb#\xf7\xbaA\xbe\xab\xbd!`\x90t\xb3[tw\x1fT\xc1\xbf\x97\xc2\x193\x9e\x91S\xbe\x1d\xc4b\xd3Y\x86\x1aq\xa2\x17E\xb7Qe%\xfe\xbf\xae\xc0\x8e_*\x8c\xa4\u0728q\x96\xc4\xc3`\xaf\t4\u55ae\xb8\x8b\x17E}\xdb!_\x89\t\x00\x00\b\x82\xf8' \x8e7\x9dc:q\u00d1\x1b\x05\x1d\r\xa00\x9de\x18Q\xfat=\x88\xc7\xee\xaa\u05bdf\u04fc\x18\xb74\x9d\xc0Zs\xb9Qt\x8b\x1b$;\x00\x18\xfaU\xe7!6\xafdm\x0f/\x8e\xa7]m\x06r\xed\xa5n\x10\xbf\x13\xd0\xfbao\xb6\xe93]\xe2\x06\xf1\x81\x9c1\xa3RW~\xfa\xfe\xc8}\x94\xf3\xf7\x00\x96\x99\xce2\xa4\xbe\xeb\x05\xf1\xb9\x00\x80\x92\x95\x1a\xbf\u040e_\x86\x82\x1fEWt\xe1\xec\x00h(\x8a\xcaN\x13]\x91g\xb9A\xb4_\x14E\u007f\xeegG\x95\x99\x00\x00\x00/\x88\xbf\x05\xeak\xa6sL'~\xd8\xf8\x01\xa97\xc3\x0e\xa2&F\xfa\xba\x1b5\xbe\xfa\xfc?\xb2^\xa93\x89\x82\xb65\x9d\xc1Z\xb3\x91\xeb-\xf7\xc2\xf8C\xa4\x0e\"\xd0\xd7{\u00ad\x17\x10\x80\xf3\xdc \xda=\b\x82\x87M\x87\xe9'/l\\$\xa7\xbb\vl\x01\xca~HA|\xd4\v\x1b\xef!\x99\x99\x0e\xb32n\x18\xde,\xa7|\x1d\x81'Lg\x19&$\xaer\x83\xe8\xb8\xe7\xfe\u064d\xa2\xbb\x00$\x06#\xbd\x80\x80M\xb4d\xc9,\xd39\xac\x97\x16\x86\xe1\xe3n\x10\xefO\xe0T\xd8\x1d\xc5\xfd\xf6\x18\xe1\xec\xeb\a\xd1\xc9$\xfb\xbep[\xa9\t\x00\x00p\xfd\xf8S\x10\xce1\x9dc:q\x83\u01b5\x14\xdf\b\xe0Y\xd3Y\x86\x82\xf0\r7\x8c\xffi\xc5\u007f\x15\x04\xc1\x83\x04\xfa:\x1b7\x11\x046\xd2\u04a5k\x9b\xceaYn\u0438\xba\xde.v\x80\xf0\r\xd8k]\xfbJ\xc0BRo\xf2\xc2\xf8\xc3$+3\xa0\xef'\u07dfq\xbf\x1bD\xbb\x8d\u055e\xb0\x13\u067dq\xaf\x1c\xed\xee\x05\xf1\xb7M\ay)\xbe?rw\x97\xb5\xbd\x01L\xeb\u026e^\x11\xf0\xb3\xba\x1f\xbd\x9b\xe4\xf3\x9f\xc5$s\x80\x95*\xb6\xdd\xf1\xbcmLg\xb0\u0187d\xd7\r\xe33J\u05b6\x03\xf4+\xd3y\xa6\xa1\x12\xc0yn\x90\xed\xe0\x86\xe1\xaf\a\xd5i\xe5&\x00H\x96^\x14\x1fo\xaf\x04\xea-7\x8a~#\xa7\xdc\v\xa8\xd6Y\xf6\xaa!p\x9a\x17\xc5\x1f_\xd9U\x1b\xaa\xd86\xa8\x8e\xe7\xd9]\x00V%p\xe6\xcc\xc5^\x14\u007f\\N\xb9#\xc0\xebL\u765et\x99\xd7)_\xed\x06\x8d\xabM'\x194\x92\xa5\x17\xc4\u756c\xbdZ\xc0\xcfM\xe7\x19b%\x84s\xdc \xda\xd9\xf7\x1bsM\x87\x19\xaf \b\x1er\xbbz\x9d\u074a\xfc\x92.\xf2\x82\xe8\x90\xd1\a\xfe\x17\xaa\xda\xf8E\xb4\xbb\x18\x87M\x10\x04\xf3\xdd \xde\x1f\xe4\xf1\xb0\v\x8a\xbdA\u0701\x12\xbb\x8eN\xea\xaf3\xd0\xe3N\x95\x9b\x00x\xce\u0615@\x87\x01\xa8\xe4\u05b4a4Z\\)\xda\x19\xf6\x9e\u0755I\x01}\xc0\r\xe3\xd3W\xf9\n\xb2R3\x9fb9\xc7t\x06\xcbZ\x91\xef\x8f\xdc\xed\x85\xd1\x1b$\xbe\x13\xe0\xfd\xa6\xf3L\v\u011dD\xb9\x9f\x176\xde\u0351\x91\xa7M\xc71)\b\x82\x87\xfd0~3\xa9\x03`\ufade\xa8y\x14\xf7\xf2\xa2\xf8x\x92Cw\xeb\x12\x1b\x8d'\u01f6\"\u007f\x1ev\xa7\u044b\x95\x04Ns\x83\xe8\x03\xab\xda:\uc82c\xd4\xf8\x05\x94\x1d\xbf\f\xa1\xd1\xc9\xd8\xe8\x1c\xb7\xe8\u0386\xf0M\xd8\xdf\xc5I\x11\xf0$\x88\xe3\\?\xda\u034b\xe3;Ld\xa8\xec\x04\x00\x00\xf8Q\xf4C9\xda\r\xc2\xd0\xccTW\x1d\xc9\xc4\v\xe3c%\x1ef\xaf\t|\xdeC\xe8j\x0f/l\\\xb8\xba\x17\x15]\xfc\x14U\u0695R\xda\x19t\xab\x9a\xfc(\xba\xd2\r\xc29%\xf1v\x10w\x9a\xce3\xa4\x16\x81<\xd1\xf5\xa3\x9d\xddp\xe4F\xd3a\xaa\xc4\r\x1a\u05faA\xf4Z\x90\x1f\x17\xf0\xa4\xe9<\x15\x97P:\xd9\r\xa2\x9d\xdc(\xfa\x8d\xe90S1\xb6\x15\xf9_\bg?\x00\x8f\x9b\xceS\x11\u03d0z\x8b\x1b\u01a7\xafl\xe7\xe2s\xeaA\xe3\xd7\x00\x16\r0\xd7jI\xb4\xe3\x97!\xc6\x193\x16yQ\xfc\x8fr\xcaW\x93\xb8\x02\xf6\xca\xc0\xf1ZF\xe0T/\x88\xb6\xf2\x82\xf8[$\x8d\xdd~W\xe9\t\x00\x00\xf0\xfd\xc6\\7\x8cv\x13y\x16\xaa\xf4\xf05\xe4\xfc(\xba\xdc\x15\xb6\x05\xf0=\xac\xb9\u007f\xae\x82p\xae\x1bD\xaf\xf1\x1a\x8d\x97|H\x89\xe3\xf8\t@\xb7\f\"\u0638\x10\xaf6\x1d\xc1\xb2V\x85d\x19\x04\xf1O]?\xdaI\xe2\x11\x00\xe6\x99\xce4$\x9e\x11p\xba\x1b\xb4\xb7\xf4\x82\xe8\xeb&\a\bUF\xb2\xf0\x82\xe8\x1b^\x10m\x01\xe2\xc3\x02\x16\x9a\xceT1\x05\xc0\x8b\xbbp\xb6s\xa3\xc6Y\x83(*5(n\x18\xfe\xda\r\x92\xed\xc6\u0185m\xd3yL!q\xa5\xdb\xd5vn\xd0\xf8\xc5K\xbf\x96\x05\x80\xab\x06\x10k\xbcv0\x1d\xc0\x9a\xba\u045d\xc5\xf1\xa1\xe8\xea\xb5\x14~\f;\x11\xb0*KE~\xc9-\xba[\xbaa|\x06\u0256\xe9@\x95\x9f\x00\x00\x00\x92\x99\x1fD'\x13\xe5\x01\x00*u\x9d\xc90c\x1c/\xf4\xc2\xf8h\x94\xd8\x1d\xc0P\xaf\fL\x18\xf1\a\xc2\xd9\u01cb\u23d1\x1c\u007fa):\x97\xf71\xd5\x04qWI\x9e\xe9\x14\x96\xb5:$K?\x8a\xfe\xcb\v\xe3\x1dPbg\x80\x17\xc3^M\xba2\x8f\x80<\xd1\r\xa2\xcd\xfd0>\x8d\\\xdb\u07bf<\x0e$\xdb^\x10\x9f\xe7\x05\xd1+A|\f\xc0\x83\xa63\x19V\x02\xfc\u03d2\xb5m\xbc0:*\f\xc3GL\a\xea\ar\xbd\xe5~\x10\x9d\\\xb26g\xec\xc1cM\xf2\x88\xc4w\xb9A\xfcN6\x1aO\x8d\xf7M\xa4*3~!\xb0a\x96e\xaf4\x9d\xc3\xea\r\xaf\u0478\u04cd\xe2\x83K\xd6fC\xfa7\xd8\xe3\xdb\x00F\v\xf7\n8\xdd\xcd\xf2-\xfc \xfaL\x95\xae[\x1d\x8a\t\x80\xe7\xb8\xe1\xc8\xf5n\x10m?V\x1b\xc0V\x84\xed\x11/\x8e\u007f\xe7\x06\u045e\x12\x0f\x05p\x9b\xe9<}\xf6\x18\x80cF\xb7\u05467O\xf4\xcdEW?Du\x1e^\xc2N\x9a\xeel:\x84e\x8d\x97\x17\xc7wxatTG|\xb9\x803\xec\xf5\x81\x10\xa0\x9b%\x1e\xea\x06\xd1Vc+\xfekDu\xff^#\xd9\xf2\x82\xf8\\7\x88\xb6V\x89C\x00^\x8f5j5\x8a\xcb!\x9cS\xb2\xf6*/\x8c\xde\x17\x04\xc1C\xa6\x13\rB\x10\x04\x0f\xb9Q|0Q\xee'\xe0jL\xef\xff\xe7\x8b\u018esl\xebG\xd1\x0f'\xfa\xe6\xba\x1f_\x0f`\xdc\x13\x06\xfd\u6a3b\x97\xe9\fVo\x05A\xf0\xa0\x175Np\xbbz9\xa5Oc\xcd,<^\n\xb8Z\xe2a^\x10m\xe6\x87\xf1i\x9c5\xabrG\xae\x87j\x02\x00x~5\xe9r7\x88\xe6@<\t\xc0\x02\u04d9\xa6\x03\x92\xf2\xa3\xe8\n/\x8cw#\x9c\xbdI\\\x85iu4@\xf3\x01|\xd0\r\xa2\xd9^\x18\x9fOrR?\xdb\xe81\x00\\\xd2\xdblSQ\xda/Pk\xe8DQ\xf4g?\x8cO\xad\a\xd1\u6133\xd7\u060aAe\x06\xa6\x03p\xaf\x80\xd3K\xd6f{aco?\x8a\xae\x98\xecg\x92\xf5B$K?\x8e\xaf\xf2\xc2\xe8\xf5%k[\t8szO4i>\xc4\u007fr\x83\xece^\x14\x1f\x1f\x04\xc1|\u04c9Lp\u00d1\x1b\xfd0>HN\xb9\x03\x80\v0\x8d\x8e\x06\bx\x92\xd2g\xdc y\xf9\xd8q\x8eI\xad\xae\x92,\b|\xa3\xd7\xf9\xa6\xc0\x8e_\xa6)6\x1aO\xbaQ\xe3\xcb^\x18oKq\x0f\x8c\x16\x1f\x1fh\x95{\x03\x1e$\xf0\u064e\xb8\xb9\x1f\xc6\a\xf9Qt\xf9\x8a\xd7qV\rM\a\x98*IN\xa7\xdd\xda\x1f\xe0\xb1\x12\x0e\x01P7\x9diRJ\xec\xe2\xc5\xf1\xed\xa6c\xac(\u02f2\xadj\xea\xbeW\xe0\x11\x80\xb66\x9dg\x12\n\x12?\x05\xf4\xed\xba\x1f_\xbb\xba\x029\x13\xd1n/\x9f\xc3\u0499\x87\n\xfc\xfe\b\xf8\x99\x1f\xc6o\xedG\xdbE\xda:E\xc0\x99\xfdh{\x8a\x9e\xf5\xc2x\x9dAu\xa6Vk\xa3\xc2\xc1\x13\x83\xeao\"\xe4\xe0`\u07cf\xa7\xc5\xf6WI\xf5n;9\xa0\x14\x0e\x06p\x10\x80\xcdLg\xea-\xcd\x17\x9d\x1f\xd3)\xff\xd3\xf3\x1aw\x99N\xb3&\x91\xe4t\xb2l\x0f\xa9{(\xc9w\n\xd8\xc4t\xa6)Z\x04\xe0\x87\x84sq=\bn\xe9\xd5w\xdbt\xa2fs\u00ce\xe3\xbcG\xd4\xe1\x00v1\x9dg\x12J\x807H\xf8\x8e\x17\x86W\xf5\xaa\x86\x83\x16/\x9eY\x04\xfe\xe3\x80Fz\xd1\xde\x14=\xe4\x85\xf1V\xbdj,\u03d2\xaf@\xfaD\xaf\xda\ub85e\xfe\x9c\xc3JRTd\xadw\x91f:\xd0dU\xf5Cp\u02b4x\xf1\xcc\xdc\xf7\x0f \xf5&\x00\xfb\x00x\x85\xe9L\xcf#\x16C\x9a\a\xf1n8\x98G9w\u05c3\xe0w$\x87b\xcbZ\x92$\x9b\xd6Y\xee\ap?\x00\xfb\x02x\xb9\xc18\x1d\x10w\xa3\xc4\u034e\x83_\xd6\xfc\xe8\xc6\t\x15\xf5\xb3,\xab\xf2$\xb9E\x92\xec\x80\x1av\x83\xb0\x1b\xc0\xdd\x00\xcdFu\xbe\u00d6a\xf4\x96\x83y \xee\x14\u02db}\u007f\xc4\x16\xac\x1d\x02Z\xbcxf7\xf4\xf6\xec\n\xbb\x13|\x1d\xa0\x9d\x014\f\xc7\xca\x01\xcc\x05p\xbb\xa0[\xbc.\xae\x99H\xb17k\u0574l\u067ay\xbd\xbe\x0f\xa1}A\xec\a`[\x98\xfb\x1c\x11\x80\xfb\x01\xfcF\xe25^\xa7sm\xaf\x1e\x86-k\x98\xa4i\xbaY\r\xdd}!\xee\fb'\x00\xaf\x01\x10\x19\x8e\x95\x83\xb8\x1b\xc2] n\x13\xcb_\xf9\xfe\u0234\xa9iP\x95\xc1S\xdfi\xf1\u265d\xa0\xfe\x1a\xa9\xb6#\x88\x1dA\u0351\xb01\x81\r\xfb\xd2\x1f\xf0$\x81G\x00>\"\xe8\x11\x02\x8f\x10\xe5\u00c5j\x0fDQ\xf4\xe7~\xf4i\x8a\x96/_\xafS\xc7\xf6bm;H\xdb\x01\xd8\x1e\xc0\x96\x00\xd6\xebe7\xa3\u007f\xa6\xfc#\xa0\xfbA\xdeG\xf1\xcez\x10\xdcQ\x85\xeb4,\xcb\x1a,\xe9\u0675\x8a4\xd8\x1a,_%pk\x87\xd8Z\xc2\xd6\x00\xb6@\u007f\x1e\xe0\x96a\xf4,\xf9\x13\x00\x9e 0\xbft0\xaf,\x9d\xb9\u04f5\xd2\xfa\x9aHR\xbd\xdd^\xbe%\xcb\xfa\xf6\x0e5\a\xc4\x1c\t/\a\xb09z\xfb\x9d\x06\x00)\x80\aI\xd3%\x9f\xf0}\u007f\xc1d\v\xf7Y\x96\xf5B\u04a2\x19y\xeemV\x93\xb3EW\x9aA\xb2\x81\x921Qz\xa5\xe3\xccz\xc1w;\x00GZ\x06\xa9+\xc7I\xa4r\x91C,R\x97Ow\x1d\xe7\x99 \b\x9e&\x99\x9a\xfe\x99L\xf8\xff\x80$\x8b\xb0\xf8\x87\aI\x00\x00\x00\x00IEND\xaeB`\x82") + +func tailscalePngBytes() ([]byte, error) { + return _tailscalePng, nil +} + +func tailscalePng() (*asset, error) { + bytes, err := tailscalePngBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "tailscale.png", size: 24866, mode: os.FileMode(436), modTime: time.Unix(1589933683, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "tailscale.png": tailscalePng, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "tailscale.png": &bintree{tailscalePng, map[string]*bintree{}}, +}} + +// RestoreAsset restores an asset under the given directory +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) + if err != nil { + return err + } + return nil +} + +// RestoreAssets restores an asset under the given directory recursively +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + cannonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) +} diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go new file mode 100644 index 0000000..9669b25 --- /dev/null +++ b/cmd/tailscale/main.go @@ -0,0 +1,526 @@ +// 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 ( + "log" + "sort" + "strings" + "sync" + "time" + + "gioui.org/app" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/op" + + "gioui.org/font/gofont" + + "tailscale.com/control/controlclient" + "tailscale.com/ipn" + "tailscale.com/tailcfg" + "tailscale.com/tailscale-android/jni" + "tailscale.com/wgengine/router" +) + +//go:generate go run github.com/go-bindata/go-bindata/go-bindata -nocompress -o logo.go tailscale.png + +type App struct { + jvm jni.JVM + appCtx jni.Object + appDir string + store *stateStore + + // updates is notifies whenever netState or browseURL changes. + updates chan struct{} + // vpnClosed is notified when the VPNService is closed while + // logged in. + vpnClosed chan struct{} + + // mu protects the following fields. + mu sync.Mutex + // netState is the most recent network state. + netState NetworkState + // browseURL is set whenever the backend wants to + // browse. + browseURL *string +} + +type clientState struct { + browseURL string + net NetworkState + // query is the search query, in lowercase. + query string + + Peers []UIPeer + // WantsEnabled is the desired state of the VPN: enabled or + // disabled. + WantsEnabled bool +} + +type NetworkState struct { + State ipn.State + NetworkMap *controlclient.NetworkMap + HasInternet bool +} + +// UIEvent is an event flowing from the UI to the backend. +type UIEvent interface{} + +type ConnectEvent struct { + Enable bool +} + +type CopyEvent struct { + Text string +} + +type SearchEvent struct { + Query string +} + +type ReauthEvent struct{} + +type LogoutEvent struct{} + +const enabledKey = "ipn_enabled" + +func main() { + a := &App{ + jvm: jni.JVMFor(app.JavaVM()), + appCtx: jni.Object(app.AppContext()), + updates: make(chan struct{}, 1), + vpnClosed: make(chan struct{}, 1), + } + appDir, err := app.DataDir() + if err != nil { + fatalErr(err) + } + a.appDir = appDir + a.store = newStateStore(a.appDir, a.jvm, a.appCtx) + events := make(chan UIEvent) + go func() { + if err := a.runBackend(events); err != nil { + fatalErr(err) + } + }() + go func() { + if err := a.runUI(events); err != nil { + fatalErr(err) + } + }() + app.Main() +} + +func (a *App) runBackend(events <-chan UIEvent) error { + var cfg *router.Config + var state NetworkState + var service jni.Object + var b *backend + b, err := newBackend(a.appDir, a.jvm, a.store, func(s *router.Config) error { + cfg = s + if b == nil || service == 0 || cfg == nil { + return nil + } + return b.updateTUN(service, cfg) + }) + if err != nil { + return err + } + defer b.CloseTUNs() + 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 + } + } + err = b.Start(func(n ipn.Notify) { + 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 != nil && state.State >= ipn.Starting { + if err := b.updateTUN(service, cfg); err != nil { + a.notifyVPNClosed() + } + } else { + b.CloseTUNs() + } + } + // Stop VPN if we logged out. + if oldState > ipn.NeedsLogin && state.State <= ipn.NeedsLogin { + if err := a.callVoidMethod(a.appCtx, "stopVPN", "()V"); err != nil { + fatalErr(err) + } + } + a.notify(state) + } + if u := n.BrowseToURL; u != nil { + a.setURL(*u) + } + if m := n.NetMap; m != nil { + state.NetworkMap = m + a.notify(state) + if service != 0 { + alarm(a.notifyExpiry(service, m.Expiry)) + } + } + }) + if err != nil { + return err + } + for { + select { + case <-alarmChan: + if m := state.NetworkMap; m != nil && service != 0 { + alarm(a.notifyExpiry(service, m.Expiry)) + } + case e := <-events: + switch e.(type) { + case ReauthEvent: + b.backend.StartLoginInteractive() + case LogoutEvent: + b.backend.Logout() + } + 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) + } + service = s + return nil + }) + a.updateNotification(service, state.State) + if m := state.NetworkMap; m != nil { + alarm(a.notifyExpiry(service, m.Expiry)) + } + if cfg != nil && state.State >= ipn.Starting { + if err := b.updateTUN(service, cfg); err != nil { + a.notifyVPNClosed() + } + } + case <-onConnectivityChange: + state.HasInternet = connected.Load().(bool) + if b != nil { + 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) { + jni.DeleteGlobalRef(env, service) + service = 0 + } + return nil + }) + if state.State >= ipn.Starting { + a.notifyVPNClosed() + } + } + } +} + +// 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 { + 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) + } + err := 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)) + }) + if err != nil { + fatalErr(err) + } + return t +} + +func (a *App) notifyVPNClosed() { + select { + case a.vpnClosed <- struct{}{}: + default: + } +} + +func (a *App) notify(state NetworkState) { + a.mu.Lock() + a.netState = state + a.mu.Unlock() + select { + case a.updates <- struct{}{}: + default: + } +} + +func (a *App) setURL(url string) { + a.mu.Lock() + a.browseURL = &url + a.mu.Unlock() + select { + case a.updates <- struct{}{}: + default: + } +} + +func (a *App) runUI(backend chan<- UIEvent) error { + w := app.NewWindow() + gofont.Register() + ui, err := newUI(a.store) + if err != nil { + return err + } + // Register an Android Fragment instance for lifecycle tracking + // of our Activity. + w.RegisterFragment("com.tailscale.ipn.Peer") + var ops op.Ops + state := new(clientState) + var peer jni.Object + state.WantsEnabled, _ = a.store.ReadBool(enabledKey, true) + ui.enabled.Value = state.WantsEnabled + for { + select { + case <-a.vpnClosed: + state.WantsEnabled = false + w.Invalidate() + case <-a.updates: + a.mu.Lock() + oldState := state.net.State + state.net = a.netState + if a.browseURL != nil { + state.browseURL = *a.browseURL + a.browseURL = nil + } + a.mu.Unlock() + a.updateState(peer, state) + w.Invalidate() + if peer != 0 { + newState := state.net.State + // Start VPN if we just logged in. + if state.WantsEnabled && oldState <= ipn.NeedsLogin && newState > ipn.NeedsLogin { + if err := a.callVoidMethod(peer, "prepareVPN", "()V"); err != nil { + fatalErr(err) + } + } + } + case peer = <-onPeerCreated: + w.Invalidate() + a.setVPNState(peer, state) + case p := <-onPeerDestroyed: + jni.Do(a.jvm, func(env jni.Env) error { + defer jni.DeleteGlobalRef(env, p) + if jni.IsSameObject(env, peer, p) { + jni.DeleteGlobalRef(env, peer) + peer = 0 + } + return nil + }) + case <-vpnPrepared: + if err := a.callVoidMethod(a.appCtx, "startVPN", "()V"); err != nil { + return err + } + case e := <-w.Events(): + switch e := e.(type) { + case system.DestroyEvent: + return e.Err + case system.FrameEvent: + gtx := layout.NewContext(&ops, e.Queue, e.Config, e.Size) + events := ui.layout(gtx, e.Insets, state) + e.Frame(gtx.Ops) + a.processUIEvents(backend, w, events, peer, state) + } + } + } +} + +func (a *App) updateState(javaPeer jni.Object, state *clientState) { + if javaPeer != 0 && state.browseURL != "" { + a.browseToURL(javaPeer, state.browseURL) + state.browseURL = "" + } + + state.Peers = nil + netMap := state.net.NetworkMap + if netMap == nil { + return + } + // Split into sections. + users := make(map[tailcfg.UserID]struct{}) + var peers []UIPeer + for _, p := range netMap.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{}{} + peers = append(peers, UIPeer{ + Owner: p.User, + Peer: p, + }) + } + // Add section (user) headers. + for u := range users { + name := netMap.UserProfiles[u].DisplayName + name = strings.ToUpper(name) + peers = append(peers, UIPeer{Owner: u, Name: name}) + } + myID := state.net.NetworkMap.User + sort.Slice(peers, func(i, j int) bool { + lhs, rhs := peers[i], peers[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 + } + return lp.Hostinfo.Hostname < rp.Hostinfo.Hostname || + lp.Hostinfo.Hostname == rp.Hostinfo.Hostname && lp.ID < rp.ID + }) + state.Peers = peers +} + +func (a *App) processUIEvents(backend chan<- UIEvent, w *app.Window, events []UIEvent, peer jni.Object, state *clientState) { + for _, e := range events { + switch e := e.(type) { + case ReauthEvent: + go func() { + backend <- e + }() + case LogoutEvent: + go func() { + backend <- e + }() + case CopyEvent: + w.WriteClipboard(e.Text) + case SearchEvent: + state.query = strings.ToLower(e.Query) + a.updateState(peer, state) + case ConnectEvent: + if e.Enable == state.WantsEnabled { + return + } + if e.Enable && peer == 0 { + return + } + state.WantsEnabled = e.Enable + a.store.WriteBool(enabledKey, e.Enable) + a.updateState(peer, state) + a.setVPNState(peer, state) + } + } +} + +func (a *App) setVPNState(peer jni.Object, state *clientState) { + var err error + if state.WantsEnabled && state.net.State > ipn.NeedsLogin { + err = a.callVoidMethod(peer, "prepareVPN", "()V") + } else { + err = a.callVoidMethod(a.appCtx, "stopVPN", "()V") + } + if err != nil { + fatalErr(err) + } +} + +func (a *App) browseToURL(peer jni.Object, url string) { + err := jni.Do(a.jvm, func(env jni.Env) error { + jurl := jni.JavaString(env, url) + return a.callVoidMethod(peer, "showURLCustomTabs", "(Ljava/lang/String;)V", 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...) + }) +} + +func fatalErr(err error) { + log.Print(err) +} diff --git a/cmd/tailscale/multitun.go b/cmd/tailscale/multitun.go new file mode 100644 index 0000000..6db00e7 --- /dev/null +++ b/cmd/tailscale/multitun.go @@ -0,0 +1,287 @@ +// 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" + + "github.com/tailscale/wireguard-go/tun" +) + +// multiTUN implements a tun.Device that supports multiple +// underlying devices. This is necessary because Android VPN devices +// have static configurations and wgengine.NewUserspaceEngineAdvanced +// 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{} +} + +// 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{}), + } + 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 + 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{}{} +} + +func (d *multiTUN) Close() error { + close(d.close) + return <-d.closeErr +} diff --git a/cmd/tailscale/pprof.go b/cmd/tailscale/pprof.go new file mode 100644 index 0000000..1c5ead8 --- /dev/null +++ b/cmd/tailscale/pprof.go @@ -0,0 +1,18 @@ +// 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. + +// +build pprof + +package main + +import ( + "net/http" + _ "net/http/pprof" +) + +func init() { + go func() { + http.ListenAndServe(":6060", nil) + }() +} diff --git a/cmd/tailscale/store.go b/cmd/tailscale/store.go new file mode 100644 index 0000000..f3acc01 --- /dev/null +++ b/cmd/tailscale/store.go @@ -0,0 +1,121 @@ +// 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" + "tailscale.com/tailscale-android/jni" +) + +// stateStore is the Go interface for a persistent storage +// backend by androidx.security.crypto.EncryptedSharedPreferences (see +// App.java). +type stateStore struct { + dataDir string + + 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(dataDir string, jvm jni.JVM, appCtx jni.Object) *stateStore { + s := &stateStore{ + dataDir: dataDir, + 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) 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 +} diff --git a/cmd/tailscale/tailscale.png b/cmd/tailscale/tailscale.png new file mode 100644 index 0000000..c978560 Binary files /dev/null and b/cmd/tailscale/tailscale.png differ diff --git a/cmd/tailscale/ui.go b/cmd/tailscale/ui.go new file mode 100644 index 0000000..c23c4ce --- /dev/null +++ b/cmd/tailscale/ui.go @@ -0,0 +1,794 @@ +// 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" + "fmt" + "image" + "image/color" + "strings" + "time" + + "gioui.org/f32" + "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" + "golang.org/x/exp/shiny/materialdesign/icons" + "tailscale.com/ipn" + "tailscale.com/tailcfg" + + _ "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 + + signin widget.Clickable + + self widget.Clickable + peers []widget.Clickable + + intro struct { + start widget.Clickable + show bool + } + + menu struct { + open widget.Clickable + dismiss Dismiss + show bool + + copy widget.Clickable + reauth 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 + } + + icons struct { + search *widget.Icon + more *widget.Icon + logo paint.ImageOp + } + + events []UIEvent +} + +// 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 +} + +const ( + headerColor = 0x496495 + infoColor = 0x3a517b + white = 0xffffff +) + +const ( + keyShowIntro = "ui.showintro" +) + +type ( + C = layout.Context + D = layout.Dimensions +) + +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 + } + logoData, err := tailscalePngBytes() + if err != nil { + return nil, err + } + logo, _, err := image.Decode(bytes.NewReader(logoData)) + if err != nil { + return nil, err + } + ui := &UI{ + theme: material.NewTheme(), + store: store, + } + ui.intro.show, _ = store.ReadBool(keyShowIntro, true) + ui.icons.search = searchIcon + ui.icons.more = moreIcon + ui.icons.logo = paint.NewImageOp(logo) + ui.icons.more.Color = rgb(white) + ui.icons.search.Color = ui.theme.Color.Hint + ui.root.Axis = layout.Vertical + ui.search.SingleLine = true + return ui, nil +} + +func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent { + ui.events = nil + if ui.enabled.Changed() { + ui.events = append(ui.events, ConnectEvent{Enable: ui.enabled.Value}) + } + ui.enabled.Value = state.WantsEnabled + + for _, e := range ui.search.Events() { + if _, ok := e.(widget.ChangeEvent); ok { + ui.events = append(ui.events, SearchEvent{Query: ui.search.Text()}) + break + } + } + for ui.menu.open.Clicked() { + ui.menu.show = !ui.menu.show + } + + netmap := state.net.NetworkMap + var localName, localAddr string + var expiry time.Time + if netmap != nil { + expiry = netmap.Expiry + localName = netmap.Hostinfo.Hostname + if addrs := netmap.Addresses; len(addrs) > 0 { + localAddr = addrs[0].IP.String() + } + } + + if ui.signin.Clicked() { + ui.events = append(ui.events, ReauthEvent{}) + } + + if ui.menuClicked(&ui.menu.copy) && localAddr != "" { + ui.copyAddress(gtx, localAddr) + } + + if ui.menuClicked(&ui.menu.reauth) { + ui.events = append(ui.events, ReauthEvent{}) + } + + if ui.menuClicked(&ui.menu.logout) { + ui.events = append(ui.events, LogoutEvent{}) + } + + 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 = 5 + n := numHeaders + len(state.Peers) + needsLogin := state.net.State == ipn.NeedsLogin + ui.root.Layout(gtx, 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.net) + case 1: + if netmap == nil { + return D{} + } + return ui.layoutLocal(gtx, sysIns, localName, localAddr) + case 2: + if state.net.State <= ipn.NeedsLogin { + return D{} + } + return ui.layoutSearchbar(gtx, sysIns) + case 3: + if !needsLogin { + return D{} + } + return ui.layoutSignIn(gtx) + case 4: + if needsLogin || state.net.HasInternet { + 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 == netmap.User { + name = "MY DEVICES" + } + return ui.layoutSection(gtx, sysIns, name) + } else { + clk := &ui.peers[pidx] + return ui.layoutPeer(gtx, sysIns, p, clk) + } + } + }) + }) + + // "Copied" message. + ui.layoutMessage(gtx, sysIns) + + // 3-dots menu. + if ui.menu.show { + ui.layoutMenu(gtx, sysIns, expiry) + } + + // "Get started". + if ui.intro.show { + if ui.intro.start.Clicked() { + ui.store.WriteBool(keyShowIntro, false) + ui.intro.show = false + } + ui.layoutIntro(gtx) + } + + return ui.events +} + +// Dismiss is a widget that detects pointer presses. +type Dismiss struct { +} + +func (d *Dismiss) Add(gtx layout.Context) { + var stack op.StackOp + stack.Push(gtx.Ops) + defer stack.Pop() + pointer.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Add(gtx.Ops) + pointer.InputOp{Tag: d}.Add(gtx.Ops) +} + +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 +} + +// layoutSignIn lays out the sign in button(s). +func (ui *UI) layoutSignIn(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(48)}.Layout(gtx, func(gtx C) D { + return layout.Center.Layout(gtx, func(gtx C) D { + signin := material.Button(ui.theme, &ui.signin, "Sign In") + signin.Background = rgb(headerColor) + return signin.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) { + fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max) + 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 { + img := ui.icons.logo + img.Add(gtx.Ops) + sz := img.Size() + aspect := float32(sz.Y) / float32(sz.X) + w := gtx.Px(unit.Dp(200)) + h := int(float32(w)*aspect + .5) + paint.PaintOp{Rect: f32.Rectangle{Max: f32.Pt(float32(w), float32(h))}}.Add(gtx.Ops) + return layout.Dimensions{Size: image.Pt(w, h)} + }) + }), + // 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.UniformInset(unit.Dp(16)).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 +} + +// layoutMenu lays out the menu activated by the 3 dots button. +func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.Time) { + ui.menu.dismiss.Add(gtx) + if ui.menu.dismiss.Dismissed(gtx) { + ui.menu.show = false + } + layout.Inset{ + Top: unit.Add(gtx, sysIns.Top, unit.Dp(2)), + Right: unit.Add(gtx, sysIns.Right, unit.Dp(2)), + }.Layout(gtx, func(gtx C) D { + return layout.NE.Layout(gtx, func(gtx C) D { + return Background{Color: argb(0x33000000), CornerRadius: unit.Dp(2)}.Layout(gtx, func(gtx C) D { + return layout.UniformInset(unit.Px(1)).Layout(gtx, func(gtx C) D { + return Background{Color: rgb(0xfafafa), CornerRadius: unit.Px(4)}.Layout(gtx, func(gtx C) D { + menu := &ui.menu + items := []struct { + btn *widget.Clickable + title string + }{ + {title: "Copy My IP Address", btn: &menu.copy}, + {title: "Reauthenticate", btn: &menu.reauth}, + {title: "Log out", btn: &menu.logout}, + } + // 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 + 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) + dims := l.Layout(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(ui.theme, 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. + var m op.MacroOp + m.Record(gtx.Ops) + f.Layout(gtx, children...) + m.Stop() + // Second pass: layout items with equal width. + minWidth = maxWidth + return f.Layout(gtx, children...) + }) + }) + }) + }) + }) +} + +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{Bottom: unit.Add(gtx, 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, clk *widget.Clickable) layout.Dimensions { + for clk.Clicked() { + if addrs := p.Peer.Addresses; len(addrs) > 0 { + ui.copyAddress(gtx, addrs[0].IP.String()) + } + } + return material.Clickable(gtx, clk, func(gtx C) D { + return layout.Inset{ + Top: unit.Dp(8), + Right: unit.Max(gtx, sysIns.Right, unit.Dp(16)), + Left: unit.Max(gtx, 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.Hostinfo.Hostname + if name == "" { + name = p.Peer.ID.String() + } + return material.H6(ui.theme, name).Layout(gtx) + }) + }), + layout.Rigid(func(gtx C) D { + var addrs []string + for _, addr := range p.Peer.Addresses { + addrs = append(addrs, addr.IP.String()) + } + l := material.Body2(ui.theme, strings.Join(addrs, ",")) + 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, sysIns.Right, unit.Dp(16)), + Left: unit.Max(gtx, 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 *NetworkState) 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, sysIns.Right, unit.Dp(8)), + Left: unit.Max(gtx, 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 { + sw := material.Switch(ui.theme, &ui.enabled) + sw.Color = rgb(white) + 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 { + if state.State <= ipn.NeedsLogin { + return D{} + } + return material.Clickable(gtx, &ui.menu.open, func(gtx C) D { + return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D { + return ui.icons.more.Layout(gtx, unit.Dp(24)) + }) + }) + }), + ) + }) + }) +} + +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 "Needs Authentication" + default: + return "Loading..." + } +} + +func (ui *UI) copyAddress(gtx layout.Context, addr string) { + ui.events = append(ui.events, CopyEvent{Text: addr}) + 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 { + for ui.self.Clicked() { + ui.copyAddress(gtx, addr) + } + return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D { + return layout.Inset{ + Right: unit.Max(gtx, sysIns.Right, unit.Dp(8)), + Left: unit.Max(gtx, 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 = ui.theme.Color.InvText + 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, sysIns.Right, unit.Dp(8)), + Left: unit.Max(gtx, 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 { + return ui.icons.search.Layout(gtx, unit.Dp(24)) + }), + layout.Flexed(1, + material.Editor(ui.theme, &ui.search, "Search by hostname...").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.TransformOp{}.Offset(f32.Pt(off, 0)) + ty := op.TransformOp{}.Offset(f32.Pt(0, off)) + + var st op.StackOp + st.Push(ops) + defer st.Pop() + + var row op.StackOp + // First row of discs. + row.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.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.Push(ops) + drawDisc(ops, discDia, rgb(0x54514d)) + tx.Add(ops) + drawDisc(ops, discDia, rgb(0xfffdfa)) + tx.Add(ops) + drawDisc(ops, discDia, rgb(0x54514d)) + row.Pop() +} + +func drawDisc(ops *op.Ops, radius float32, col color.RGBA) { + var st op.StackOp + st.Push(ops) + defer st.Pop() + r2 := radius * .5 + dr := f32.Rectangle{Max: f32.Pt(radius, radius)} + clip.Rect{ + Rect: dr, + NE: r2, NW: r2, SE: r2, SW: r2, + }.Op(ops).Add(ops) + paint.ColorOp{Color: col}.Add(ops) + paint.PaintOp{Rect: dr}.Add(ops) +} + +// background lays out a widget and draws a color background behind +// it. +type Background struct { + Color color.RGBA + CornerRadius unit.Value +} + +func (b Background) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { + var m op.MacroOp + m.Record(gtx.Ops) + dims := w(gtx) + sz := dims.Size + m.Stop() + // Clip corners, if any. + if r := gtx.Px(b.CornerRadius); r > 0 { + rr := float32(r) + clip.Rect{ + Rect: f32.Rectangle{Max: f32.Point{ + X: float32(sz.X), + Y: float32(sz.Y), + }}, + NE: rr, NW: rr, SE: rr, SW: rr, + }.Op(gtx.Ops).Add(gtx.Ops) + } + fill{b.Color}.Layout(gtx, sz) + m.Add() + return dims +} + +type fill struct { + col color.RGBA +} + +func (f fill) Layout(gtx layout.Context, sz image.Point) layout.Dimensions { + var st op.StackOp + st.Push(gtx.Ops) + defer st.Pop() + dr := f32.Rectangle{Max: layout.FPt(sz)} + paint.ColorOp{Color: f.col}.Add(gtx.Ops) + paint.PaintOp{Rect: dr}.Add(gtx.Ops) + return layout.Dimensions{Size: sz} +} + +func rgb(c uint32) color.RGBA { + return argb((0xff << 24) | c) +} + +func argb(c uint32) color.RGBA { + return color.RGBA{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.` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..392cd3c --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module tailscale.com/tailscale-android + +go 1.14 + +require ( + gioui.org v0.0.0-20200524174833-ad93e3212824 + gioui.org/cmd v0.0.0-20200508063126-0ad8f85c05e7 // indirect + github.com/tailscale/wireguard-go v0.0.0-20200515231107-62868271d710 + golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 + golang.org/x/sys v0.0.0-20200501052902-10377860bb8e + tailscale.com v0.98.1-0.20200524053159-e6b84f2159ba +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c0a6f3b --- /dev/null +++ b/go.sum @@ -0,0 +1,187 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +gioui.org v0.0.0-20200503190452-8d9612f9aa46/go.mod h1:AHI9rFr6AEEHCb8EPVtb/p5M+NMJRKH58IOp8O3Je04= +gioui.org v0.0.0-20200524174833-ad93e3212824 h1:vQP8qwWQXun8lXmj707eZcsuem2X6aF3w0z8CnrL8PI= +gioui.org v0.0.0-20200524174833-ad93e3212824/go.mod h1:AHI9rFr6AEEHCb8EPVtb/p5M+NMJRKH58IOp8O3Je04= +gioui.org/cmd v0.0.0-20200502185633-fa7f9d3ba897 h1:o/P0x46brgIfnKt9QpQs51o30wxAGWgnq1kECJgJ+s4= +gioui.org/cmd v0.0.0-20200502185633-fa7f9d3ba897/go.mod h1:MA/AKwBq+dTw6ajU3Vtju89BJ6a/zWIg8JLXC2nja08= +gioui.org/cmd v0.0.0-20200508063126-0ad8f85c05e7 h1:gziosxqkR5CpjUTPWQKmkLFwpUMRGNuMcmCvurF58hg= +gioui.org/cmd v0.0.0-20200508063126-0ad8f85c05e7/go.mod h1:pSCbdDbqNf8zuUHR3zv2om4R2946FUJBOI618vt5AoE= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY= +github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= +github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A= +github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= +github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA= +github.com/coreos/go-iptables v0.4.5 h1:DpHb9vJrZQEFMcVLFKAAGMUVX0XoRC0ptCthinRYm38= +github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-bindata/go-bindata v1.0.0 h1:DZ34txDXWn1DyWa+vQf7V9ANc2ILTtrEjtlsdJRF26M= +github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE= +github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg= +github.com/goreleaser/nfpm v1.1.10/go.mod h1:oOcoGRVwvKIODz57NUfiRwFWGfn00NXdgnn6MrYtO5k= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= +github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4 h1:nwOc1YaOrYJ37sEBrtWZrdqzK22hiJs3GpDmP3sR2Yw= +github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.9.8 h1:VMAMUUOh+gaxKTMk+zqbjsSjsIcUcL/LF4o63i82QyA= +github.com/klauspost/compress v1.9.8/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= +github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= +github.com/mdlayher/netlink v1.1.0 h1:mpdLgm+brq10nI9zM1BpX1kpDbh3NLl3RSnVq6ZSkfg= +github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/peterbourgon/ff v1.7.0/go.mod h1:/KKxnU5cBj4w21jEMj4Rway/kslRP6XAOHh7CH8AyAM= +github.com/peterbourgon/ff/v2 v2.0.0/go.mod h1:xjwr+t+SjWm4L46fcj/D+Ap+6ME7+HqFzaP22pP5Ggk= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/tailscale/hujson v0.0.0-20190930033718-5098e564d9b3/go.mod h1:STqf+YV0ADdzk4ejtXFsGqDpATP9JoL0OB+hiFQbkdE= +github.com/tailscale/winipcfg-go v0.0.0-20200213045944-185b07f8233f/go.mod h1:x880GWw5fvrl2DVTQ04ttXQD4DuppTt1Yz6wLibbjNE= +github.com/tailscale/winipcfg-go v0.0.0-20200413171540-609dcf2df55f h1:uFj5bslHsMzxIM8UTjAhq4VXeo6GfNW91rpoh/WMJaY= +github.com/tailscale/winipcfg-go v0.0.0-20200413171540-609dcf2df55f/go.mod h1:x880GWw5fvrl2DVTQ04ttXQD4DuppTt1Yz6wLibbjNE= +github.com/tailscale/wireguard-go v0.0.0-20200317013323-239518935266 h1:Dhtc6KmHWCBWukI47jywK+9vIxFQxFIL5qxSIgg7QdQ= +github.com/tailscale/wireguard-go v0.0.0-20200317013323-239518935266/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4= +github.com/tailscale/wireguard-go v0.0.0-20200416194755-23aababa2084 h1:8FolyyuEIqny/MUD2VrTE8Damx0bG+UGix7OXXm0EeY= +github.com/tailscale/wireguard-go v0.0.0-20200416194755-23aababa2084/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4= +github.com/tailscale/wireguard-go v0.0.0-20200424121617-8d10f231531a h1:HMkTFyhcvZaKf7+7T76rks4HqB83fptUemBIfLGI6TM= +github.com/tailscale/wireguard-go v0.0.0-20200424121617-8d10f231531a/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4= +github.com/tailscale/wireguard-go v0.0.0-20200515231107-62868271d710 h1:I6aq3tOYbZob9uwhGpr7R266qTeU9PFqS6NnpfCqEzo= +github.com/tailscale/wireguard-go v0.0.0-20200515231107-62868271d710/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4= +github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= +github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= +github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= +github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +go4.org/mem v0.0.0-20200411205429-f77f31c81751 h1:sgGPu7KkyLjyOYOwKFHCtnfosdSuM5q2Gud23Y/+nzw= +go4.org/mem v0.0.0-20200411205429-f77f31c81751/go.mod h1:NEYvpHWemiG/E5UWfaN5QAIGZeT1sa0Z2UNk6oeMb/k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4 h1:QmwruyY+bKbDDL0BaglrbZABEali68eoMFhTZpCjYVA= +golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200317142112-1b76d66859c6 h1:TjszyFsQsyZNHwdVdZ5m7bjmreu0znc2kRYsEml9/Ww= +golang.org/x/crypto v0.0.0-20200317142112-1b76d66859c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191003212358-c178f38b412c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200316230553-a7d97aace0b0 h1:4Khi5GeNOkZS5DqSBRn4Sy7BE6GuxwOqARPqfurkdNk= +golang.org/x/sys v0.0.0-20200316230553-a7d97aace0b0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d h1:62ap6LNOjDU6uGmKXHJbSfciMoV+FeI1sRXx/pLDL44= +golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e h1:hq86ru83GdWTlfQFZGO4nZJTU4Bs2wfHl8oFHRaXsfc= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e h1:1xWUkZQQ9Z9UuZgNaIR6OQOE7rUFglXUUBZlO+dGg6I= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d h1:/iIZNFGxc/a7C3yWjGcnboV+Tkc7mxr+p6fDztwoxuM= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gortc.io/stun v1.22.1 h1:96mOdDATYRqhYB+TZdenWBg4CzL2Ye5kPyBXQ8KAB+8= +gortc.io/stun v1.22.1/go.mod h1:XD5lpONVyjvV3BgOyJFNo0iv6R2oZB4L+weMqxts+zg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +inet.af/netaddr v0.0.0-20200417213433-f9e5bcc2d6ea h1:DpXewrGVf9+vvYQFrNGj9v34bXMuTVQv+2wuULTNV8I= +inet.af/netaddr v0.0.0-20200417213433-f9e5bcc2d6ea/go.mod h1:qqYzz/2whtrbWJvt+DNWQyvekNN4ePQZcg2xc2/Yjww= +inet.af/netaddr v0.0.0-20200430175045-5aaf2097c7fc h1:We3b/z+7i9LV4Ls0yWve5vYIlnAPSPeqxKVgZseRDBs= +inet.af/netaddr v0.0.0-20200430175045-5aaf2097c7fc/go.mod h1:qqYzz/2whtrbWJvt+DNWQyvekNN4ePQZcg2xc2/Yjww= +rsc.io/goversion v1.2.0 h1:SPn+NLTiAG7w30IRK/DKp1BjvpWabYgxlLp/+kx5J8w= +rsc.io/goversion v1.2.0/go.mod h1:Eih9y/uIBS3ulggl7KNJ09xGSLcuNaLgmvvqa07sgfo= +tailscale.com v0.96.2-0.20200501084119-0068e574073e h1:Jaw1TwXru/ulQmsP9b51o2eYoDBYoMaZP6e/yJc0ZaI= +tailscale.com v0.96.2-0.20200501084119-0068e574073e/go.mod h1:lSEmuWEgZd+EAwk+m6d4Zmk7I5UsEBUGyBBzpf90XV8= +tailscale.com v0.96.2-0.20200501140524-7b901fdbbc51 h1:JlkWBx63N3Su6NnEu0+hlNzud+wqprlR/QxHhmySAzg= +tailscale.com v0.96.2-0.20200501140524-7b901fdbbc51/go.mod h1:lSEmuWEgZd+EAwk+m6d4Zmk7I5UsEBUGyBBzpf90XV8= +tailscale.com v0.98.1-0.20200524053159-e6b84f2159ba h1:P+BSN4UNWBuehhB9s17WDynwjVxgQqV+asPThTiMOwg= +tailscale.com v0.98.1-0.20200524053159-e6b84f2159ba/go.mod h1:qhqIOURjwBji/8sD4E3HmTou3eyp/2HfFuPLdjs7ge8= diff --git a/jni/gojni.h b/jni/gojni.h new file mode 100644 index 0000000..6895305 --- /dev/null +++ b/jni/gojni.h @@ -0,0 +1,25 @@ +__attribute__ ((visibility ("hidden"))) jint _jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version); +__attribute__ ((visibility ("hidden"))) jint _jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args); +__attribute__ ((visibility ("hidden"))) jint _jni_DetachCurrentThread(JavaVM *vm); +__attribute__ ((visibility ("hidden"))) jclass _jni_FindClass(JNIEnv *env, const char *name); +__attribute__ ((visibility ("hidden"))) jthrowable _jni_ExceptionOccurred(JNIEnv *env); +__attribute__ ((visibility ("hidden"))) void _jni_ExceptionClear(JNIEnv *env); +__attribute__ ((visibility ("hidden"))) jclass _jni_GetObjectClass(JNIEnv *env, jobject obj); +__attribute__ ((visibility ("hidden"))) jmethodID _jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig); +__attribute__ ((visibility ("hidden"))) jmethodID _jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig); +__attribute__ ((visibility ("hidden"))) jsize _jni_GetStringLength(JNIEnv *env, jstring str); +__attribute__ ((visibility ("hidden"))) const jchar *_jni_GetStringChars(JNIEnv *env, jstring str); +__attribute__ ((visibility ("hidden"))) jstring _jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len); +__attribute__ ((visibility ("hidden"))) jboolean _jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2); +__attribute__ ((visibility ("hidden"))) jobject _jni_NewGlobalRef(JNIEnv *env, jobject obj); +__attribute__ ((visibility ("hidden"))) void _jni_DeleteGlobalRef(JNIEnv *env, jobject obj); +__attribute__ ((visibility ("hidden"))) jint _jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args); +__attribute__ ((visibility ("hidden"))) jobject _jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args); +__attribute__ ((visibility ("hidden"))) void _jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args); +__attribute__ ((visibility ("hidden"))) jobject _jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args); +__attribute__ ((visibility ("hidden"))) jint _jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args); +__attribute__ ((visibility ("hidden"))) void _jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args); +__attribute__ ((visibility ("hidden"))) jbyteArray _jni_NewByteArray(JNIEnv *env, jsize length); +__attribute__ ((visibility ("hidden"))) jbyte *_jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr); +__attribute__ ((visibility ("hidden"))) void _jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode); +__attribute__ ((visibility ("hidden"))) jsize _jni_GetArrayLength(JNIEnv *env, jarray arr); diff --git a/jni/jni.c b/jni/jni.c new file mode 100644 index 0000000..45b1d27 --- /dev/null +++ b/jni/jni.c @@ -0,0 +1,101 @@ +#include + +jint _jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) { + return (*vm)->AttachCurrentThread(vm, p_env, thr_args); +} + +jint _jni_DetachCurrentThread(JavaVM *vm) { + return (*vm)->DetachCurrentThread(vm); +} + +jint _jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version) { + return (*vm)->GetEnv(vm, (void **)env, version); +} + +jclass _jni_FindClass(JNIEnv *env, const char *name) { + return (*env)->FindClass(env, name); +} + +jthrowable _jni_ExceptionOccurred(JNIEnv *env) { + return (*env)->ExceptionOccurred(env); +} + +void _jni_ExceptionClear(JNIEnv *env) { + (*env)->ExceptionClear(env); +} + +jclass _jni_GetObjectClass(JNIEnv *env, jobject obj) { + return (*env)->GetObjectClass(env, obj); +} + +jmethodID _jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetMethodID(env, clazz, name, sig); +} + +jmethodID _jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetStaticMethodID(env, clazz, name, sig); +} + +jsize _jni_GetStringLength(JNIEnv *env, jstring str) { + return (*env)->GetStringLength(env, str); +} + +const jchar *_jni_GetStringChars(JNIEnv *env, jstring str) { + return (*env)->GetStringChars(env, str, NULL); +} + +jstring _jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) { + return (*env)->NewString(env, unicodeChars, len); +} + +jboolean _jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2) { + return (*env)->IsSameObject(env, ref1, ref2); +} + +jobject _jni_NewGlobalRef(JNIEnv *env, jobject obj) { + return (*env)->NewGlobalRef(env, obj); +} + +void _jni_DeleteGlobalRef(JNIEnv *env, jobject obj) { + (*env)->DeleteGlobalRef(env, obj); +} + +void _jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { + (*env)->CallStaticVoidMethodA(env, cls, method, args); +} + +jint _jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { + return (*env)->CallStaticIntMethodA(env, cls, method, args); +} + +jobject _jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { + return (*env)->CallStaticObjectMethodA(env, cls, method, args); +} + +jobject _jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { + return (*env)->CallObjectMethodA(env, obj, method, args); +} + +jint _jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { + return (*env)->CallIntMethodA(env, obj, method, args); +} + +void _jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { + (*env)->CallVoidMethodA(env, obj, method, args); +} + +jbyteArray _jni_NewByteArray(JNIEnv *env, jsize length) { + return (*env)->NewByteArray(env, length); +} + +jbyte *_jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr) { + return (*env)->GetByteArrayElements(env, arr, NULL); +} + +void _jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode) { + (*env)->ReleaseByteArrayElements(env, arr, elems, mode); +} + +jsize _jni_GetArrayLength(JNIEnv *env, jarray arr) { + return (*env)->GetArrayLength(env, arr); +} diff --git a/jni/jni.go b/jni/jni.go new file mode 100644 index 0000000..bcc25db --- /dev/null +++ b/jni/jni.go @@ -0,0 +1,267 @@ +// 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 jni + +// Package jni implements various helper functions for communicating with the Android JVM +// though JNI. + +import ( + "errors" + "fmt" + "reflect" + "runtime" + "unicode/utf16" + "unsafe" +) + +/* +#cgo CFLAGS: -Wall + +#include +#include + +#include "gojni.h" +*/ +import "C" + +type JVM struct { + jvm *C.JavaVM +} + +type Env struct { + env *C.JNIEnv +} + +type ( + Class C.jclass + Object C.jobject + MethodID C.jmethodID + String C.jstring + ByteArray C.jbyteArray + Value uint64 // All JNI types fit into 64-bits. +) + +func JVMFor(jvmPtr uintptr) JVM { + return JVM{ + jvm: (*C.JavaVM)(unsafe.Pointer(jvmPtr)), + } +} + +func EnvFor(envPtr uintptr) Env { + return Env{ + env: (*C.JNIEnv)(unsafe.Pointer(envPtr)), + } +} + +// Do invokes a function with a temporary JVM environment. The +// environment is not valid after the function returns. +func Do(vm JVM, f func(env Env) error) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + var env *C.JNIEnv + var detach bool + if res := C._jni_GetEnv(vm.jvm, &env, C.JNI_VERSION_1_6); res != C.JNI_OK { + if res != C.JNI_EDETACHED { + panic(fmt.Errorf("JNI GetEnv failed with error %d", res)) + } + if C._jni_AttachCurrentThread(vm.jvm, &env, nil) != C.JNI_OK { + panic(errors.New("runInJVM: AttachCurrentThread failed")) + } + detach = true + } + + if detach { + defer func() { + C._jni_DetachCurrentThread(vm.jvm) + }() + } + return f(Env{env}) +} + +func varArgs(args []Value) *C.jvalue { + if len(args) == 0 { + return nil + } + return (*C.jvalue)(unsafe.Pointer(&args[0])) +} + +func IsSameObject(e Env, ref1, ref2 Object) bool { + same := C._jni_IsSameObject(e.env, C.jobject(ref1), C.jobject(ref2)) + return same == C.JNI_TRUE +} + +func CallStaticIntMethod(e Env, cls Class, method MethodID, args ...Value) (int, error) { + res := C._jni_CallStaticIntMethodA(e.env, C.jclass(cls), C.jmethodID(method), varArgs(args)) + return int(res), exception(e) +} + +func CallStaticVoidMethod(e Env, cls Class, method MethodID, args ...Value) error { + C._jni_CallStaticVoidMethodA(e.env, C.jclass(cls), C.jmethodID(method), varArgs(args)) + return exception(e) +} + +func CallVoidMethod(e Env, obj Object, method MethodID, args ...Value) error { + C._jni_CallVoidMethodA(e.env, C.jobject(obj), C.jmethodID(method), varArgs(args)) + return exception(e) +} + +func CallStaticObjectMethod(e Env, cls Class, method MethodID, args ...Value) (Object, error) { + res := C._jni_CallStaticObjectMethodA(e.env, C.jclass(cls), C.jmethodID(method), varArgs(args)) + return Object(res), exception(e) +} + +func CallObjectMethod(e Env, obj Object, method MethodID, args ...Value) (Object, error) { + res := C._jni_CallObjectMethodA(e.env, C.jobject(obj), C.jmethodID(method), varArgs(args)) + return Object(res), exception(e) +} + +func CallIntMethod(e Env, obj Object, method MethodID, args ...Value) (int32, error) { + res := C._jni_CallIntMethodA(e.env, C.jobject(obj), C.jmethodID(method), varArgs(args)) + return int32(res), exception(e) +} + +// GetByteArrayElements returns the contents of the array. +func GetByteArrayElements(e Env, jarr ByteArray) []byte { + size := C._jni_GetArrayLength(e.env, C.jarray(jarr)) + elems := C._jni_GetByteArrayElements(e.env, C.jbyteArray(jarr)) + defer C._jni_ReleaseByteArrayElements(e.env, C.jbyteArray(jarr), elems, 0) + backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:size:size] + s := make([]byte, len(backing)) + copy(s, backing) + return s +} + +// NewByteArray allocates a Java byte array with the content. It +// panics if the allocation fails. +func NewByteArray(e Env, content []byte) ByteArray { + jarr := C._jni_NewByteArray(e.env, C.jsize(len(content))) + if jarr == 0 { + panic(fmt.Errorf("jni: NewByteArray(%d) failed", len(content))) + } + elems := C._jni_GetByteArrayElements(e.env, jarr) + defer C._jni_ReleaseByteArrayElements(e.env, jarr, elems, 0) + backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:len(content):len(content)] + copy(backing, content) + return ByteArray(jarr) +} + +// ClassLoader returns a reference to the Java ClassLoader associated +// with obj. +func ClassLoaderFor(e Env, obj Object) Object { + cls := GetObjectClass(e, obj) + getClassLoader := GetMethodID(e, cls, "getClassLoader", "()Ljava/lang/ClassLoader;") + clsLoader, err := CallObjectMethod(e, Object(obj), getClassLoader) + if err != nil { + // Class.getClassLoader should never fail. + panic(err) + } + return Object(clsLoader) +} + +// LoadClass invokes the underlying ClassLoader's loadClass method and +// returns the class. +func LoadClass(e Env, loader Object, class string) (Class, error) { + cls := GetObjectClass(e, loader) + loadClass := GetMethodID(e, cls, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;") + name := JavaString(e, class) + loaded, err := CallObjectMethod(e, loader, loadClass, Value(name)) + if err != nil { + return 0, err + } + return Class(loaded), exception(e) +} + +// exception returns an error corresponding to the pending +// exception, and clears it. exceptionError returns nil if no +// exception is pending. +func exception(e Env) error { + thr := C._jni_ExceptionOccurred(e.env) + if thr == 0 { + return nil + } + C._jni_ExceptionClear(e.env) + cls := GetObjectClass(e, Object(thr)) + toString := GetMethodID(e, cls, "toString", "()Ljava/lang/String;") + msg, err := CallObjectMethod(e, Object(thr), toString) + if err != nil { + return err + } + return errors.New(GoString(e, String(msg))) +} + +// GetObjectClass returns the Java Class for an Object. +func GetObjectClass(e Env, obj Object) Class { + if obj == 0 { + panic("null object") + } + cls := C._jni_GetObjectClass(e.env, C.jobject(obj)) + if err := exception(e); err != nil { + // GetObjectClass should never fail. + panic(err) + } + return Class(cls) +} + +// GetStaticMethodID returns the id for a static method. It panics if the method +// wasn't found. +func GetStaticMethodID(e Env, cls Class, name, signature string) MethodID { + mname := C.CString(name) + defer C.free(unsafe.Pointer(mname)) + msig := C.CString(signature) + defer C.free(unsafe.Pointer(msig)) + m := C._jni_GetStaticMethodID(e.env, C.jclass(cls), mname, msig) + if err := exception(e); err != nil { + panic(err) + } + return MethodID(m) +} + +// GetMethodID returns the id for a method. It panics if the method +// wasn't found. +func GetMethodID(e Env, cls Class, name, signature string) MethodID { + mname := C.CString(name) + defer C.free(unsafe.Pointer(mname)) + msig := C.CString(signature) + defer C.free(unsafe.Pointer(msig)) + m := C._jni_GetMethodID(e.env, C.jclass(cls), mname, msig) + if err := exception(e); err != nil { + panic(err) + } + return MethodID(m) +} + +func NewGlobalRef(e Env, obj Object) Object { + return Object(C._jni_NewGlobalRef(e.env, C.jobject(obj))) +} + +func DeleteGlobalRef(e Env, obj Object) { + C._jni_DeleteGlobalRef(e.env, C.jobject(obj)) +} + +// JavaString converts the string to a JVM jstring. +func JavaString(e Env, str string) String { + if str == "" { + return 0 + } + utf16Chars := utf16.Encode([]rune(str)) + res := C._jni_NewString(e.env, (*C.jchar)(unsafe.Pointer(&utf16Chars[0])), C.int(len(utf16Chars))) + return String(res) +} + +// GoString converts the JVM jstring to a Go string. +func GoString(e Env, str String) string { + if str == 0 { + return "" + } + strlen := C._jni_GetStringLength(e.env, C.jstring(str)) + chars := C._jni_GetStringChars(e.env, C.jstring(str)) + var utf16Chars []uint16 + hdr := (*reflect.SliceHeader)(unsafe.Pointer(&utf16Chars)) + hdr.Data = uintptr(unsafe.Pointer(chars)) + hdr.Cap = int(strlen) + hdr.Len = int(strlen) + utf8 := utf16.Decode(utf16Chars) + return string(utf8) +}