cmd/tailscale,java: implement file sharing

Fixes tailscale/tailscale#1809

Signed-off-by: Elias Naur <mail@eliasnaur.com>
pull/14/head
Elias Naur 5 years ago
parent 331bc1e30a
commit 10ded1bad2

@ -9,7 +9,7 @@
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" <application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:name=".App" android:allowBackup="false"> android:name=".App" android:allowBackup="false">
<activity android:name="org.gioui.GioActivity" <activity android:name="IPNActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.GioApp" android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
@ -22,6 +22,28 @@
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" /> <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity> </activity>
<service android:name=".IPNService" <service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE"> android:permission="android.permission.BIND_VPN_SERVICE">

@ -8,7 +8,11 @@ import android.app.Application;
import android.app.Activity; import android.app.Activity;
import android.app.Fragment; import android.app.Fragment;
import android.app.FragmentTransaction; import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
@ -16,21 +20,28 @@ import android.content.SharedPreferences;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.pm.PackageInfo; import android.content.pm.PackageInfo;
import android.content.pm.Signature; import android.content.pm.Signature;
import android.provider.MediaStore;
import android.provider.Settings; import android.provider.Settings;
import android.net.ConnectivityManager; import android.net.ConnectivityManager;
import android.net.Uri; import android.net.Uri;
import android.net.VpnService; import android.net.VpnService;
import android.view.View; import android.view.View;
import android.os.Build; import android.os.Build;
import android.os.Environment;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.webkit.MimeTypeMap;
import java.io.IOException; import java.io.IOException;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.security.crypto.EncryptedSharedPreferences; import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey; import androidx.security.crypto.MasterKey;
@ -41,6 +52,15 @@ import org.gioui.Gio;
public class App extends Application { public class App extends Application {
private final static String PEER_TAG = "peer"; private final static String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2;
private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
private final static Handler mainHandler = new Handler(Looper.getMainLooper()); private final static Handler mainHandler = new Handler(Looper.getMainLooper());
@Override public void onCreate() { @Override public void onCreate() {
@ -48,6 +68,11 @@ public class App extends Application {
// Load and initialize the Go library. // Load and initialize the Go library.
Gio.init(this); Gio.init(this);
registerNetworkCallback(); registerNetworkCallback();
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
} }
private void registerNetworkCallback() { private void registerNetworkCallback() {
@ -209,6 +234,53 @@ public class App extends Application {
return null; return null;
} }
String insertMedia(String name, String mimeType) throws IOException {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent fileIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
PendingIntent pending = PendingIntent.getActivity(this, 0, fileIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
static native void onVPNPrepared(); static native void onVPNPrepared();
private static native void onConnectivityChanged(boolean connected); private static native void onConnectivityChanged(boolean connected);
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
} }

@ -0,0 +1,117 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.net.Uri;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView;
public final class IPNActivity extends Activity {
private GioView view;
@Override public void onCreate(Bundle state) {
super.onCreate(state);
view = new GioView(this);
setContentView(view);
handleIntent();
}
@Override public void onNewIntent(Intent i) {
setIntent(i);
handleIntent();
}
private void handleIntent() {
Intent it = getIntent();
String act = it.getAction();
String[] texts;
Uri[] uris;
if (Intent.ACTION_SEND.equals(act)) {
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uris = extraUris.toArray(new Uri[0]);
texts = new String[uris.length];
} else {
return;
}
String mime = it.getType();
int nitems = uris.length;
String[] items = new String[nitems];
String[] mimes = new String[nitems];
int[] types = new int[nitems];
String[] names = new String[nitems];
long[] sizes = new long[nitems];
int nfiles = 0;
for (int i = 0; i < uris.length; i++) {
String text = texts[i];
Uri uri = uris[i];
if (text != null) {
types[nfiles] = 1; // FileTypeText
names[nfiles] = "file.txt";
mimes[nfiles] = mime;
items[nfiles] = text;
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
sizes[nfiles] = 0;
nfiles++;
} else if (uri != null) {
Cursor c = getContentResolver().query(uri, null, null, null, null);
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
c.moveToFirst();
String name = c.getString(nameCol);
long size = c.getLong(sizeCol);
types[nfiles] = 2; // FileTypeURI
mimes[nfiles] = mime;
items[nfiles] = uri.toString();
names[nfiles] = name;
sizes[nfiles] = size;
nfiles++;
}
}
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override public void onDestroy() {
view.destroy();
super.onDestroy();
}
@Override public void onStart() {
super.onStart();
view.start();
}
@Override public void onStop() {
view.stop();
super.onStop();
}
@Override public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c);
view.configurationChanged();
}
@Override public void onLowMemory() {
super.onLowMemory();
view.onLowMemory();
}
@Override public void onBackPressed() {
if (!view.backPressed())
super.onBackPressed();
}
}

@ -6,7 +6,6 @@ package com.tailscale.ipn;
import android.os.Build; import android.os.Build;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.NotificationChannel;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.VpnService; import android.net.VpnService;
@ -21,14 +20,6 @@ public class IPNService extends VpnService {
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT"; public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT"; 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) { @Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) { if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
close(); close();
@ -70,9 +61,7 @@ public class IPNService extends VpnService {
} }
public void notify(String title, String message) { public void notify(String title, String message) {
createNotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_DEFAULT); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText(message) .setContentText(message)
@ -82,29 +71,18 @@ public class IPNService extends VpnService {
.setPriority(NotificationCompat.PRIORITY_DEFAULT); .setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this); NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(NOTIFY_NOTIFICATION_ID, builder.build()); nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
} }
public void updateStatusNotification(String title, String message) { public void updateStatusNotification(String title, String message) {
createNotificationChannel(STATUS_CHANNEL_ID, STATUS_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_LOW); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText(message) .setContentText(message)
.setContentIntent(configIntent()) .setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW); .setPriority(NotificationCompat.PRIORITY_LOW);
startForeground(STATUS_NOTIFICATION_ID, builder.build()); startForeground(App.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 connect();

@ -36,6 +36,9 @@ var (
// onGoogleToken receives google ID tokens. // onGoogleToken receives google ID tokens.
onGoogleToken = make(chan string) onGoogleToken = make(chan string)
// onFileShare receives file sharing intents.
onFileShare = make(chan []File, 1)
) )
const ( const (
@ -129,3 +132,45 @@ func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C.jclass,
} }
} }
} }
//export Java_com_tailscale_ipn_App_onShareIntent
func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclass, nfiles C.jint, jtypes C.jintArray, jmimes C.jobjectArray, jitems C.jobjectArray, jnames C.jobjectArray, jsizes C.jlongArray) {
const (
typeNone = 0
typeInline = 1
typeURI = 2
)
jenv := (*jni.Env)(unsafe.Pointer(env))
types := jni.GetIntArrayElements(jenv, jni.IntArray(jtypes))
mimes := jni.GetStringArrayElements(jenv, jni.ObjectArray(jmimes))
items := jni.GetStringArrayElements(jenv, jni.ObjectArray(jitems))
names := jni.GetStringArrayElements(jenv, jni.ObjectArray(jnames))
sizes := jni.GetLongArrayElements(jenv, jni.LongArray(jsizes))
var files []File
for i := 0; i < int(nfiles); i++ {
f := File{
Type: FileType(types[i]),
MIMEType: mimes[i],
Name: names[i],
}
if f.Name == "" {
f.Name = "file.bin"
}
switch f.Type {
case FileTypeText:
f.Text = items[i]
f.Size = int64(len(f.Text))
case FileTypeURI:
f.URI = items[i]
f.Size = sizes[i]
default:
panic("unknown file type")
}
files = append(files, f)
}
select {
case <-onFileShare:
default:
}
onFileShare <- files
}

@ -5,9 +5,17 @@
package main package main
import ( import (
"context"
"crypto/sha1" "crypto/sha1"
"errors"
"fmt" "fmt"
"io"
"log" "log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -20,9 +28,12 @@ import (
"inet.af/netaddr" "inet.af/netaddr"
"github.com/tailscale/tailscale-android/jni" "github.com/tailscale/tailscale-android/jni"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/net/dns" "tailscale.com/net/dns"
"tailscale.com/net/netns" "tailscale.com/net/netns"
"tailscale.com/paths"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
"tailscale.com/wgengine/router" "tailscale.com/wgengine/router"
@ -41,10 +52,10 @@ type App struct {
prefs chan *ipn.Prefs prefs chan *ipn.Prefs
// browseURLs receives URLs when the backend wants to browse. // browseURLs receives URLs when the backend wants to browse.
browseURLs chan string browseURLs chan string
// targetsLoaded receives lists of file targets.
// backend is the channel for events from the frontend to the targetsLoaded chan FileTargets
// backend. // invalidates receives whenever the window should be refreshed.
backendEvents chan UIEvent invalidates chan struct{}
} }
var ( var (
@ -52,6 +63,30 @@ var (
googleClass jni.Class googleClass jni.Class
) )
type FileTargets struct {
Targets []*apitype.FileTarget
Err error
}
type File struct {
Type FileType
Name string
Size int64
MIMEType string
// URI of the file, valid if Type is FileTypeURI.
URI string
// Text is the content of the file, if Type is FileTypeText.
Text string
}
// FileSendInfo describes the state of an ongoing file send operation.
type FileSendInfo struct {
State FileSendState
// Progress tracks the progress of the transfer from 0.0 to 1.0. Valid
// only when State is FileSendStarted.
Progress float64
}
type clientState struct { type clientState struct {
browseURL string browseURL string
backend BackendState backend BackendState
@ -61,6 +96,14 @@ type clientState struct {
Peers []UIPeer Peers []UIPeer
} }
type FileType uint8
// FileType constants are known to IPNActivity.java.
const (
FileTypeText FileType = 1
FileTypeURI FileType = 2
)
type ExitStatus uint8 type ExitStatus uint8
const ( const (
@ -72,7 +115,17 @@ const (
ExitOnline ExitOnline
) )
type ExitNode struct { type FileSendState uint8
const (
FileSendNotStarted FileSendState = iota
FileSendConnecting
FileSendTransferring
FileSendComplete
FileSendFailed
)
type Peer struct {
Label string Label string
Online bool Online bool
ID tailcfg.StableNodeID ID tailcfg.StableNodeID
@ -84,11 +137,11 @@ type BackendState struct {
NetworkMap *netmap.NetworkMap NetworkMap *netmap.NetworkMap
LostInternet bool LostInternet bool
// Exits are the peers that can act as exit node. // Exits are the peers that can act as exit node.
Exits []ExitNode Exits []Peer
// ExitState describes the state of our exit node. // ExitState describes the state of our exit node.
ExitStatus ExitStatus ExitStatus ExitStatus
// Exit is our current exit node, if any. // Exit is our current exit node, if any.
Exit ExitNode Exit Peer
} }
// UIEvent is an event flowing from the UI to the backend. // UIEvent is an event flowing from the UI to the backend.
@ -114,6 +167,12 @@ type OAuth2Event struct {
Token *tailcfg.Oauth2Token Token *tailcfg.Oauth2Token
} }
type FileSendEvent struct {
Target *apitype.FileTarget
Context context.Context
Updates func(FileSendInfo)
}
// UIEvent types. // UIEvent types.
type ( type (
ToggleEvent struct{} ToggleEvent struct{}
@ -121,6 +180,7 @@ type (
WebAuthEvent struct{} WebAuthEvent struct{}
GoogleAuthEvent struct{} GoogleAuthEvent struct{}
LogoutEvent struct{} LogoutEvent struct{}
FileTargetsEvent struct{}
) )
// serverOAuthID is the OAuth ID of the tailscale-android server, used // serverOAuthID is the OAuth ID of the tailscale-android server, used
@ -141,6 +201,8 @@ func main() {
netStates: make(chan BackendState, 1), netStates: make(chan BackendState, 1),
browseURLs: make(chan string, 1), browseURLs: make(chan string, 1),
prefs: make(chan *ipn.Prefs, 1), prefs: make(chan *ipn.Prefs, 1),
targetsLoaded: make(chan FileTargets, 1),
invalidates: make(chan struct{}, 1),
} }
err := jni.Do(a.jvm, func(env *jni.Env) error { err := jni.Do(a.jvm, func(env *jni.Env) error {
loader := jni.ClassLoaderFor(env, a.appCtx) loader := jni.ClassLoaderFor(env, a.appCtx)
@ -174,6 +236,7 @@ func (a *App) runBackend() error {
if err != nil { if err != nil {
fatalErr(err) fatalErr(err)
} }
paths.AppSharedDir.Store(appDir)
type configPair struct { type configPair struct {
rcfg *router.Config rcfg *router.Config
dcfg *dns.OSConfig dcfg *dns.OSConfig
@ -223,12 +286,33 @@ func (a *App) runBackend() error {
service jni.Object // of IPNService service jni.Object // of IPNService
signingIn bool signingIn bool
) )
var (
waitingFilesDone = make(chan struct{})
waitingFiles bool
processingFiles bool
)
processFiles := func() {
if !waitingFiles || processingFiles {
return
}
processingFiles = true
waitingFiles = false
go func() {
if err := a.processWaitingFiles(b.backend); err != nil {
log.Printf("processWaitingFiles: %v", err)
}
waitingFilesDone <- struct{}{}
}()
}
for { for {
select { select {
case err := <-startErr: case err := <-startErr:
if err != nil { if err != nil {
return err return err
} }
case <-waitingFilesDone:
processingFiles = false
processFiles()
case s := <-configs: case s := <-configs:
cfg = s cfg = s
if b == nil || service == 0 || cfg.rcfg == nil { if b == nil || service == 0 || cfg.rcfg == nil {
@ -290,6 +374,18 @@ func (a *App) runBackend() error {
if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline { if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline {
a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.") a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.")
} }
targets, err := b.backend.FileTargets()
if err != nil {
// Construct a user-visible error message.
if b.backend.State() != ipn.Running {
err = fmt.Errorf("Not connected to tailscale")
} else {
err = fmt.Errorf("Failed to load device list")
}
}
a.targetsLoaded <- FileTargets{targets, err}
waitingFiles = n.FilesWaiting != nil
processFiles()
case <-alarmChan: case <-alarmChan:
if m := state.NetworkMap; m != nil && service != 0 { if m := state.NetworkMap; m != nil && service != 0 {
alarm(a.notifyExpiry(service, m.Expiry)) alarm(a.notifyExpiry(service, m.Expiry))
@ -385,6 +481,90 @@ func (a *App) runBackend() error {
} }
} }
func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error {
files, err := b.WaitingFiles()
if err != nil {
return err
}
var aerr error
for _, f := range files {
if err := a.downloadFile(b, f); err != nil && aerr == nil {
aerr = err
}
}
return aerr
}
func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.WaitingFile) (cerr error) {
in, _, err := b.OpenFile(f.Name)
if err != nil {
return err
}
defer in.Close()
ext := filepath.Ext(f.Name)
mimeType := mime.TypeByExtension(ext)
var mediaURI string
err = jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
insertMedia := jni.GetMethodID(env, cls, "insertMedia", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")
jname := jni.JavaString(env, f.Name)
jmime := jni.JavaString(env, mimeType)
uri, err := jni.CallObjectMethod(env, a.appCtx, insertMedia, jni.Value(jname), jni.Value(jmime))
if err != nil {
return err
}
mediaURI = jni.GoString(env, jni.String(uri))
return nil
})
if err != nil {
return fmt.Errorf("insertMedia: %w", err)
}
deleteURI := func(uri string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "deleteUri", "(Ljava/lang/String;)V")
juri := jni.JavaString(env, uri)
return jni.CallVoidMethod(env, a.appCtx, m, jni.Value(juri))
})
}
out, err := a.openURI(mediaURI, "w")
if err != nil {
deleteURI(mediaURI)
return fmt.Errorf("openUri: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
deleteURI(mediaURI)
return fmt.Errorf("copy: %w", err)
}
if err := out.Close(); err != nil {
deleteURI(mediaURI)
return fmt.Errorf("close: %w", err)
}
if err := a.notifyFile(mediaURI, f.Name); err != nil {
fatalErr(err)
}
return b.DeleteFile(f.Name)
}
// openURI calls a.appCtx.getContentResolver().openFileDescriptor on uri and
// mode and returns the detached file descriptor.
func (a *App) openURI(uri, mode string) (*os.File, error) {
var f *os.File
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
openURI := jni.GetMethodID(env, cls, "openUri", "(Ljava/lang/String;Ljava/lang/String;)I")
juri := jni.JavaString(env, uri)
jmode := jni.JavaString(env, mode)
fd, err := jni.CallIntMethod(env, a.appCtx, openURI, jni.Value(juri), jni.Value(jmode))
if err != nil {
return err
}
f = os.NewFile(uintptr(fd), "media-store")
return nil
})
return f, err
}
func (a *App) isChromeOS() bool { func (a *App) isChromeOS() bool {
var chromeOS bool var chromeOS bool
err := jni.Do(a.jvm, func(env *jni.Env) error { err := jni.Do(a.jvm, func(env *jni.Env) error {
@ -425,7 +605,7 @@ func (s *BackendState) updateExitNodes() {
} }
myExit := p.StableID == exitID myExit := p.StableID == exitID
hasMyExit = hasMyExit || myExit hasMyExit = hasMyExit || myExit
exit := ExitNode{ exit := Peer{
Label: p.DisplayName(true), Label: p.DisplayName(true),
Online: canRoute, Online: canRoute,
ID: p.StableID, ID: p.StableID,
@ -445,8 +625,8 @@ func (s *BackendState) updateExitNodes() {
}) })
if !hasMyExit { if !hasMyExit {
// Insert node missing from netmap. // Insert node missing from netmap.
s.Exit = ExitNode{Label: "Unknown device", ID: exitID} s.Exit = Peer{Label: "Unknown device", ID: exitID}
s.Exits = append([]ExitNode{s.Exit}, s.Exits...) s.Exits = append([]Peer{s.Exit}, s.Exits...)
} }
} }
@ -561,6 +741,16 @@ func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer {
return t return t
} }
func (a *App) notifyFile(uri, msg string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
notify := jni.GetMethodID(env, cls, "notifyFile", "(Ljava/lang/String;Ljava/lang/String;)V")
juri := jni.JavaString(env, uri)
jmsg := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, a.appCtx, notify, jni.Value(juri), jni.Value(jmsg))
})
}
func (a *App) pushNotify(service jni.Object, title, msg string) error { func (a *App) pushNotify(service jni.Object, title, msg string) error {
return jni.Do(a.jvm, func(env *jni.Env) error { return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, service) cls := jni.GetObjectClass(env, service)
@ -615,6 +805,8 @@ func (a *App) runUI() error {
// activity is the most recent Android Activity reference as reported // activity is the most recent Android Activity reference as reported
// by Gio ViewEvents. // by Gio ViewEvents.
activity jni.Object activity jni.Object
// files is list of files from the most recent file sharing intent.
files []File
) )
deleteActivityRef := func() { deleteActivityRef := func() {
if activity == 0 { if activity == 0 {
@ -677,6 +869,15 @@ func (a *App) runUI() error {
case <-onVPNRevoked: case <-onVPNRevoked:
ui.ShowMessage("VPN access denied or another VPN service is always-on") ui.ShowMessage("VPN access denied or another VPN service is always-on")
w.Invalidate() w.Invalidate()
case files = <-onFileShare:
ui.ShowShareDialog()
w.Invalidate()
backendEvents <- FileTargetsEvent{}
case t := <-a.targetsLoaded:
ui.FillShareDialog(t.Targets, t.Err)
w.Invalidate()
case <-a.invalidates:
w.Invalidate()
case e := <-w.Events(): case e := <-w.Events():
switch e := e.(type) { switch e := e.(type) {
case app.ViewEvent: case app.ViewEvent:
@ -699,6 +900,7 @@ func (a *App) runUI() error {
if e.Type == system.CommandBack { if e.Type == system.CommandBack {
if ui.onBack() { if ui.onBack() {
e.Cancel = true e.Cancel = true
w.Invalidate()
} }
} }
case system.FrameEvent: case system.FrameEvent:
@ -707,7 +909,7 @@ func (a *App) runUI() error {
gtx := layout.NewContext(&ops, e) gtx := layout.NewContext(&ops, e)
events := ui.layout(gtx, ins, state) events := ui.layout(gtx, ins, state)
e.Frame(gtx.Ops) e.Frame(gtx.Ops)
a.processUIEvents(w, events, activity, state) a.processUIEvents(w, events, activity, state, files)
} }
} }
} }
@ -829,7 +1031,7 @@ func requestBackend(e UIEvent) {
}() }()
} }
func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState) { func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState, files []File) {
for _, e := range events { for _, e := range events {
switch e := e.(type) { switch e := e.(type) {
case ReauthEvent: case ReauthEvent:
@ -858,8 +1060,109 @@ func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, s
case SearchEvent: case SearchEvent:
state.query = strings.ToLower(e.Query) state.query = strings.ToLower(e.Query)
a.updateState(act, state) a.updateState(act, state)
case FileSendEvent:
a.sendFiles(e, files)
}
}
}
func (a *App) sendFiles(e FileSendEvent, files []File) {
go func() {
var totalSize int64
for _, f := range files {
totalSize += f.Size
}
if totalSize == 0 {
totalSize = 1
}
var totalSent int64
progress := func(n int64) {
totalSent += n
e.Updates(FileSendInfo{
State: FileSendTransferring,
Progress: float64(totalSent) / float64(totalSize),
})
a.invalidate()
} }
defer a.invalidate()
for _, f := range files {
if err := a.sendFile(e.Context, e.Target, f, progress); err != nil {
if errors.Is(err, context.Canceled) {
return
} }
e.Updates(FileSendInfo{
State: FileSendFailed,
})
return
}
}
e.Updates(FileSendInfo{
State: FileSendComplete,
})
}()
}
func (a *App) invalidate() {
select {
case a.invalidates <- struct{}{}:
default:
}
}
func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget, f File, progress func(n int64)) error {
var body io.Reader
switch f.Type {
case FileTypeText:
body = strings.NewReader(f.Text)
case FileTypeURI:
f, err := a.openURI(f.URI, "r")
if err != nil {
return err
}
defer f.Close()
body = f
default:
panic("unknown file type")
}
body = &progressReader{r: body, size: f.Size, progress: progress}
dstURL := target.PeerAPIURL + "/v0/put/" + url.PathEscape(f.Name)
req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, body)
if err != nil {
return err
}
req.ContentLength = f.Size
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("PUT failed: %s", res.Status)
}
return nil
}
// progressReader wraps an io.Reader to call a progress function
// on every non-zero Read.
type progressReader struct {
r io.Reader
bytes int64
size int64
eof bool
progress func(n int64)
}
func (r *progressReader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
// The request body may be read after http.Client.Do returns, see
// https://github.com/golang/go/issues/30597. Don't update progress if the
// file has been read.
r.eof = r.eof || errors.Is(err, io.EOF)
if !r.eof && r.bytes < r.size {
r.progress(int64(n))
r.bytes += int64(n)
}
return n, err
} }
func (a *App) signOut() { func (a *App) signOut() {

@ -6,6 +6,7 @@ package main
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
@ -25,6 +26,7 @@ import (
"gioui.org/widget/material" "gioui.org/widget/material"
"golang.org/x/exp/shiny/materialdesign/icons" "golang.org/x/exp/shiny/materialdesign/icons"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@ -92,15 +94,35 @@ type UI struct {
t0 time.Time t0 time.Time
} }
shareDialog struct {
show bool
dismiss Dismiss
list layout.List
// peers are the nodes ready to receive files.
targets []shareTarget
loaded bool
error error
}
icons struct { icons struct {
search *widget.Icon search *widget.Icon
more *widget.Icon more *widget.Icon
exitStatus *widget.Icon exitStatus *widget.Icon
done *widget.Icon
error *widget.Icon
logo paint.ImageOp logo paint.ImageOp
google paint.ImageOp google paint.ImageOp
} }
} }
type shareTarget struct {
btn widget.Clickable
target *apitype.FileTarget
info FileSendInfo
cancel func()
updates <-chan FileSendInfo
}
type signinType uint8 type signinType uint8
// An UIPeer is either a peer or a section header // An UIPeer is either a peer or a section header
@ -161,6 +183,14 @@ func newUI(store *stateStore) (*UI, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
doneIcon, err := widget.NewIcon(icons.ActionCheckCircle)
if err != nil {
return nil, err
}
errorIcon, err := widget.NewIcon(icons.AlertErrorOutline)
if err != nil {
return nil, err
}
logo, _, err := image.Decode(bytes.NewReader(tailscaleLogo)) logo, _, err := image.Decode(bytes.NewReader(tailscaleLogo))
if err != nil { if err != nil {
return nil, err return nil, err
@ -189,15 +219,20 @@ func newUI(store *stateStore) (*UI, error) {
ui.icons.search = searchIcon ui.icons.search = searchIcon
ui.icons.more = moreIcon ui.icons.more = moreIcon
ui.icons.exitStatus = exitStatus ui.icons.exitStatus = exitStatus
ui.icons.done = doneIcon
ui.icons.error = errorIcon
ui.icons.logo = paint.NewImageOp(logo) ui.icons.logo = paint.NewImageOp(logo)
ui.icons.google = paint.NewImageOp(google) ui.icons.google = paint.NewImageOp(google)
ui.icons.more.Color = rgb(white) ui.icons.more.Color = rgb(white)
ui.icons.search.Color = mulAlpha(ui.theme.Palette.Fg, 0xbb) ui.icons.search.Color = mulAlpha(ui.theme.Palette.Fg, 0xbb)
ui.icons.exitStatus.Color = rgb(white) ui.icons.exitStatus.Color = rgb(white)
ui.icons.done.Color = ui.theme.Palette.ContrastBg
ui.icons.error.Color = rgb(0xcc6539)
ui.root.Axis = layout.Vertical ui.root.Axis = layout.Vertical
ui.intro.list.Axis = layout.Vertical ui.intro.list.Axis = layout.Vertical
ui.search.SingleLine = true ui.search.SingleLine = true
ui.exitDialog.list.Axis = layout.Vertical ui.exitDialog.list.Axis = layout.Vertical
ui.shareDialog.list.Axis = layout.Vertical
return ui, nil return ui, nil
} }
@ -207,11 +242,18 @@ func mulAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
} }
func (ui *UI) onBack() bool { func (ui *UI) onBack() bool {
if !ui.menu.show { switch {
return false case ui.menu.show:
}
ui.menu.show = false ui.menu.show = false
return true return true
case ui.shareDialog.show:
ui.shareDialog.show = false
return true
case ui.exitDialog.show:
ui.exitDialog.show = false
return true
}
return false
} }
func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent { func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent {
@ -284,6 +326,42 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat
events = append(events, LogoutEvent{}) events = append(events, LogoutEvent{})
} }
for i := range ui.shareDialog.targets {
t := &ui.shareDialog.targets[i]
select {
case t.info = <-t.updates:
default:
}
if !t.btn.Clicked() {
continue
}
switch t.info.State {
case FileSendTransferring, FileSendConnecting:
t.cancel()
t.info.State = FileSendNotStarted
t.updates = nil
continue
}
t.info = FileSendInfo{
State: FileSendConnecting,
}
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
updates := make(chan FileSendInfo, 1)
t.updates = updates
events = append(events, FileSendEvent{
Target: t.target,
Context: ctx,
Updates: func(info FileSendInfo) {
select {
case <-updates:
default:
}
updates <- info
},
})
}
for len(ui.peers) < len(state.Peers) { for len(ui.peers) < len(state.Peers) {
ui.peers = append(ui.peers, widget.Clickable{}) ui.peers = append(ui.peers, widget.Clickable{})
} }
@ -360,6 +438,8 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat
ui.layoutExitNodeDialog(gtx, sysIns, state.backend.Exits) ui.layoutExitNodeDialog(gtx, sysIns, state.backend.Exits)
ui.layoutShareDialog(gtx, sysIns)
// Popup messages. // Popup messages.
ui.layoutMessage(gtx, sysIns) ui.layoutMessage(gtx, sysIns)
@ -380,6 +460,31 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat
return events return events
} }
func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) {
ui.shareDialog.error = err
ui.shareDialog.loaded = true
targetSet := make(map[tailcfg.NodeID]int)
if ui.shareDialog.show {
// Update rather than replace list.
for i, t := range ui.shareDialog.targets {
targetSet[t.target.Node.ID] = i
}
} else {
ui.shareDialog.targets = nil
}
for _, t := range targets {
if i, ok := targetSet[t.Node.ID]; ok {
ui.shareDialog.targets[i].target = t
} else {
ui.shareDialog.targets = append(ui.shareDialog.targets, shareTarget{target: t})
}
}
}
func (ui *UI) ShowShareDialog() {
ui.shareDialog.show = true
}
func (ui *UI) ShowMessage(msg string) { func (ui *UI) ShowMessage(msg string) {
ui.message.text = msg ui.message.text = msg
ui.message.t0 = time.Now() ui.message.t0 = time.Now()
@ -623,9 +728,118 @@ func (ui *UI) menuClicked(btn *widget.Clickable) bool {
return cl return cl
} }
// layoutExitNodeDialog lays out the exit node selection dialog. If the user changed the node, // layoutShareDialog lays out the file sharing dialog shown on file send intents (ACTION_SEND, ACTION_SEND_MULTIPLE).
// true is returned along with the node. func (ui *UI) layoutShareDialog(gtx layout.Context, sysIns system.Insets) {
func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []ExitNode) { d := &ui.shareDialog
if d.dismiss.Dismissed(gtx) {
ui.shareDialog.show = false
}
if !d.show {
return
}
d.dismiss.Add(gtx, argb(0x66000000))
layout.Inset{
Top: unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)),
Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)),
Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)),
Left: unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)),
}.Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Px(unit.Dp(250))
gtx.Constraints.Max.X = gtx.Constraints.Min.X
return layoutDialog(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
// Header.
d := layout.Inset{
Top: unit.Dp(16),
Right: unit.Dp(20),
Left: unit.Dp(20),
Bottom: unit.Dp(16),
}.Layout(gtx, func(gtx C) D {
l := material.Body1(ui.theme, "Share via Tailscale")
l.Font.Weight = text.Bold
return l.Layout(gtx)
})
// Swallow clicks to title.
var c widget.Clickable
gtx.Constraints.Min = d.Size
c.Layout(gtx)
return d
}),
layout.Rigid(func(gtx C) D {
if d.loaded {
return D{}
}
return layout.UniformInset(unit.Dp(50)).Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
sz := gtx.Px(unit.Dp(32))
gtx.Constraints.Min = image.Pt(sz, sz)
gtx.Constraints.Max = gtx.Constraints.Min
return material.Loader(ui.theme).Layout(gtx)
})
})
}),
layout.Rigid(func(gtx C) D {
if d.error == nil {
return D{}
}
sz := gtx.Px(unit.Dp(50))
gtx.Constraints.Min.Y = sz
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx C) D {
return layout.W.Layout(gtx, func(gtx C) D {
return material.Body2(ui.theme, d.error.Error()).Layout(gtx)
})
})
}),
layout.Flexed(1, func(gtx C) D {
gtx.Constraints.Min.Y = 0
return d.list.Layout(gtx, len(d.targets), func(gtx C, idx int) D {
node := &d.targets[idx]
target := node.target.Node
lbl := target.ComputedName
offline := target.Online != nil && !*target.Online
if offline {
lbl = lbl + " (offline)"
}
w := material.Body2(ui.theme, lbl)
if offline {
w.Color = rgb(0xbbbbbb)
gtx.Queue = nil
}
return material.Clickable(gtx, &node.btn, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, w.Layout),
layout.Rigid(func(gtx C) D {
sz := gtx.Px(unit.Dp(16))
gtx.Constraints.Min = image.Pt(sz, sz)
switch node.info.State {
case FileSendConnecting:
return material.Loader(ui.theme).Layout(gtx)
case FileSendTransferring:
return material.ProgressCircle(ui.theme, float32(node.info.Progress)).Layout(gtx)
case FileSendFailed:
return ui.icons.error.Layout(gtx, unit.Dp(16))
case FileSendComplete:
return ui.icons.done.Layout(gtx, unit.Dp(16))
default:
return D{}
}
}),
)
})
})
})
}),
)
})
})
})
}
// layoutExitNodeDialog lays out the exit node selection dialog.
func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []Peer) {
d := &ui.exitDialog d := &ui.exitDialog
if d.dismiss.Dismissed(gtx) { if d.dismiss.Dismissed(gtx) {
d.show = false d.show = false
@ -663,10 +877,7 @@ func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exi
// Add "none" exit node. // Add "none" exit node.
n := len(exits) + 1 n := len(exits) + 1
return d.list.Layout(gtx, n, func(gtx C, idx int) D { return d.list.Layout(gtx, n, func(gtx C, idx int) D {
switch idx { node := Peer{Label: "None", Online: true}
case n - 1:
}
node := ExitNode{Label: "None", Online: true}
if idx >= 1 { if idx >= 1 {
node = exits[idx-1] node = exits[idx-1]
} }

@ -4,7 +4,7 @@ go 1.16
require ( require (
eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b
gioui.org v0.0.0-20210623091900-5b8da35a798b gioui.org v0.0.0-20210626160534-a87206c3647c
gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b
golang.org/x/exp v0.0.0-20191227195350-da58074b4299 golang.org/x/exp v0.0.0-20191227195350-da58074b4299
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22

@ -15,8 +15,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b h1:J9r7EuPdhvBTafg34EqrObAm/bDEaDh7LvhKJPGficE= eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b h1:J9r7EuPdhvBTafg34EqrObAm/bDEaDh7LvhKJPGficE=
eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b/go.mod h1:CYwJpIhpzVfoHpFXGlXjSx9mXMWtHt4XXmZb6RjumRc= eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b/go.mod h1:CYwJpIhpzVfoHpFXGlXjSx9mXMWtHt4XXmZb6RjumRc=
gioui.org v0.0.0-20210518185901-8611894b4bb3/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= gioui.org v0.0.0-20210518185901-8611894b4bb3/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
gioui.org v0.0.0-20210623091900-5b8da35a798b h1:N5MMMh46Yg3o8UDFpfEzIIXi4K9x02QoHl89kjmqTOc= gioui.org v0.0.0-20210626160534-a87206c3647c h1:PUxsHY4Ig4hn1gXO1iuJzplNdjSEzQbMOagao20UHOU=
gioui.org v0.0.0-20210623091900-5b8da35a798b/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= gioui.org v0.0.0-20210626160534-a87206c3647c/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b h1:qPEu8AvPmYvUyZmQcOzAj+vc+BmdVtWrhCeHIm+pGJU= gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b h1:qPEu8AvPmYvUyZmQcOzAj+vc+BmdVtWrhCeHIm+pGJU=
gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b/go.mod h1:SgBjN+8Jtku8vvbXc6AsTebvxPY9+h68PUjSKGNryvQ= gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b/go.mod h1:SgBjN+8Jtku8vvbXc6AsTebvxPY9+h68PUjSKGNryvQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

Loading…
Cancel
Save