diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index b1b268b..efb9be9 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -11,6 +11,7 @@ import android.app.Fragment; import android.app.FragmentTransaction; import android.app.NotificationChannel; import android.app.PendingIntent; +import android.app.UiModeManager; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.ContentValues; @@ -21,6 +22,7 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.PackageInfo; import android.content.pm.Signature; +import android.content.res.Configuration; import android.provider.MediaStore; import android.provider.Settings; import android.net.ConnectivityManager; @@ -394,4 +396,9 @@ public class App extends Application { return sb.toString(); } + + boolean isTV() { + UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE); + return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; + } } diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go index bd99078..e0aad51 100644 --- a/cmd/tailscale/main.go +++ b/cmd/tailscale/main.go @@ -871,8 +871,13 @@ func (a *App) runUI() error { ui.runningExit = p.AdvertisesExitNode() ui.exitLAN.Value = p.ExitNodeAllowLANAccess w.Invalidate() - case state.browseURL = <-a.browseURLs: + case url := <-a.browseURLs: ui.signinType = noSignin + if a.isTV() { + ui.ShowQRCode(url) + } else { + state.browseURL = url + } w.Invalidate() a.updateState(activity, state) case newState := <-a.netStates: @@ -948,6 +953,21 @@ func (a *App) runUI() error { } } +func (a *App) isTV() bool { + var istv bool + err := jni.Do(a.jvm, func(env *jni.Env) error { + cls := jni.GetObjectClass(env, a.appCtx) + m := jni.GetMethodID(env, cls, "isTV", "()Z") + b, err := jni.CallBooleanMethod(env, a.appCtx, m) + istv = b + return err + }) + if err != nil { + fatalErr(err) + } + return istv +} + // isReleaseSigned reports whether the app is signed with a release // signature. func (a *App) isReleaseSigned() bool { diff --git a/cmd/tailscale/ui.go b/cmd/tailscale/ui.go index eb27f14..eac6ca8 100644 --- a/cmd/tailscale/ui.go +++ b/cmd/tailscale/ui.go @@ -25,6 +25,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + qrcode "github.com/skip2/go-qrcode" "golang.org/x/exp/shiny/materialdesign/icons" "inet.af/netaddr" "tailscale.com/client/tailscale/apitype" @@ -76,6 +77,11 @@ type UI struct { showDebugMenu bool runningExit bool // are we an exit node now? + qr struct { + show bool + op paint.ImageOp + } + intro struct { list layout.List start widget.Clickable @@ -255,6 +261,8 @@ func (ui *UI) onBack() bool { func (ui *UI) activeDialog() *bool { switch { + case ui.qr.show: + return &ui.qr.show case ui.menu.show: return &ui.menu.show case ui.shareDialog.show: @@ -414,6 +422,9 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat const numHeaders = 6 n := numHeaders + len(state.Peers) needsLogin := state.backend.State == ipn.NeedsLogin + if !needsLogin { + ui.qr.show = false + } rootGtx := gtx if ui.activeDialog() != nil { rootGtx.Queue = nil @@ -494,9 +505,20 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat ui.layoutMenu(gtx, sysIns, expiry, exitID != "" || len(state.backend.Exits) > 0) } + if ui.qr.show { + ui.layoutQR(gtx, sysIns) + } + return events } +func (ui *UI) layoutQR(gtx layout.Context, sysIns system.Insets) layout.Dimensions { + fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max) + return layout.Center.Layout(gtx, func(gtx C) D { + return drawImage(gtx, ui.qr.op, unit.Dp(300)) + }) +} + func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) { ui.shareDialog.error = err ui.shareDialog.loaded = true @@ -527,6 +549,16 @@ func (ui *UI) ShowMessage(msg string) { ui.message.t0 = time.Now() } +func (ui *UI) ShowQRCode(url string) { + ui.qr.show = true + q, err := qrcode.New(url, qrcode.Medium) + if err != nil { + fatalErr(err) + return + } + ui.qr.op = paint.NewImageOp(q.Image(512)) +} + // Dismiss is a widget that detects pointer presses. type Dismiss struct { } diff --git a/go.mod b/go.mod index f06e18d..867ba15 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b gioui.org v0.0.0-20220228171706-79bfd3adbd32 gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/exp v0.0.0-20210722180016-6781d3edade3 golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 golang.zx2c4.com/wireguard v0.0.0-20211116201604-de7c702ace45 diff --git a/go.sum b/go.sum index 6f64470..3b4b5b5 100644 --- a/go.sum +++ b/go.sum @@ -369,6 +369,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=