diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go index 47f80e4..fc9a988 100644 --- a/cmd/tailscale/main.go +++ b/cmd/tailscale/main.go @@ -18,6 +18,7 @@ import ( "gioui.org/layout" "gioui.org/op" "golang.org/x/oauth2" + "inet.af/netaddr" "github.com/tailscale/tailscale-android/jni" "tailscale.com/ipn" @@ -61,15 +62,43 @@ type clientState struct { Peers []UIPeer } +type ExitStatus uint8 + +const ( + // No exit node selected. + ExitNone ExitStatus = iota + // Exit node selected and exists, but is offline or missing. + ExitOffline + // Exit node selected and online. + ExitOnline +) + +type ExitNode struct { + Label string + Online bool + ID tailcfg.StableNodeID +} + type BackendState struct { + Prefs *ipn.Prefs State ipn.State NetworkMap *netmap.NetworkMap LostInternet bool + // Exits are the peers that can act as exit node. + Exits []ExitNode + // ExitState describes the state of our exit node. + ExitStatus ExitStatus + // Exit is our current exit node, if any. + Exit ExitNode } // UIEvent is an event flowing from the UI to the backend. type UIEvent interface{} +type RouteAllEvent struct { + ID tailcfg.StableNodeID +} + type ConnectEvent struct { Enable bool } @@ -178,7 +207,6 @@ func (a *App) runBackend() error { alarmChan = timer.C } } - var prefs *ipn.Prefs notifications := make(chan ipn.Notify, 1) startErr := make(chan error) // Start from a goroutine to avoid deadlock when Start @@ -208,16 +236,18 @@ func (a *App) runBackend() error { } configErrs <- b.updateTUN(service, cfg) case n := <-notifications: + exitWasOnline := state.ExitStatus == ExitOnline if p := n.Prefs; p != nil { - first := prefs == nil - prefs = p.Clone() + first := state.Prefs == nil + state.Prefs = p.Clone() + state.updateExitNodes() if first { - prefs.Hostname = a.hostname() - prefs.OSVersion = a.osVersion() - prefs.DeviceModel = a.modelName() - go b.backend.SetPrefs(prefs) + state.Prefs.Hostname = a.hostname() + state.Prefs.OSVersion = a.osVersion() + state.Prefs.DeviceModel = a.modelName() + go b.backend.SetPrefs(state.Prefs) } - a.setPrefs(prefs) + a.setPrefs(state.Prefs) } if s := n.State; s != nil { oldState := state.State @@ -249,11 +279,16 @@ func (a *App) runBackend() error { } if m := n.NetMap; m != nil { state.NetworkMap = m + state.updateExitNodes() a.notify(state) if service != 0 { alarm(a.notifyExpiry(service, m.Expiry)) } } + // Notify if a previously online exit is not longer online (or missing). + if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline { + a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.") + } case <-alarmChan: if m := state.NetworkMap; m != nil && service != 0 { alarm(a.notifyExpiry(service, m.Expiry)) @@ -263,8 +298,8 @@ func (a *App) runBackend() error { case OAuth2Event: go b.backend.Login(e.Token) case ToggleEvent: - prefs.WantRunning = !prefs.WantRunning - go b.backend.SetPrefs(prefs) + state.Prefs.WantRunning = !state.Prefs.WantRunning + go b.backend.SetPrefs(state.Prefs) case WebAuthEvent: if !signingIn { go b.backend.StartLoginInteractive() @@ -273,8 +308,11 @@ func (a *App) runBackend() error { case LogoutEvent: go b.backend.Logout() case ConnectEvent: - prefs.WantRunning = e.Enable - go b.backend.SetPrefs(prefs) + state.Prefs.WantRunning = e.Enable + go b.backend.SetPrefs(state.Prefs) + case RouteAllEvent: + state.Prefs.ExitNodeID = e.ID + go b.backend.SetPrefs(state.Prefs) } case s := <-onConnect: jni.Do(a.jvm, func(env *jni.Env) error { @@ -337,6 +375,56 @@ func (a *App) isChromeOS() bool { return chromeOS } +func (s *BackendState) updateExitNodes() { + s.ExitStatus = ExitNone + var exitID tailcfg.StableNodeID + if p := s.Prefs; p != nil { + exitID = p.ExitNodeID + if exitID != "" { + s.ExitStatus = ExitOffline + } + } + hasMyExit := exitID == "" + s.Exits = nil + var peers []*tailcfg.Node + if s.NetworkMap != nil { + peers = s.NetworkMap.Peers + } + for _, p := range peers { + canRoute := false + for _, r := range p.AllowedIPs { + if r == netaddr.MustParseIPPrefix("0.0.0.0/0") || r == netaddr.MustParseIPPrefix("::/0") { + canRoute = true + break + } + } + myExit := p.StableID == exitID + hasMyExit = hasMyExit || myExit + exit := ExitNode{ + Label: p.DisplayName(true), + Online: canRoute, + ID: p.StableID, + } + if myExit { + s.Exit = exit + if canRoute { + s.ExitStatus = ExitOnline + } + } + if canRoute || myExit { + s.Exits = append(s.Exits, exit) + } + } + sort.Slice(s.Exits, func(i, j int) bool { + return s.Exits[i].Label < s.Exits[j].Label + }) + if !hasMyExit { + // Insert node missing from netmap. + s.Exit = ExitNode{Label: "Unknown device", ID: exitID} + s.Exits = append([]ExitNode{s.Exit}, s.Exits...) + } +} + // hostname builds a hostname from android.os.Build fields, in place of a // useless os.Hostname(). func (a *App) hostname() string { @@ -442,17 +530,20 @@ func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer { default: return time.NewTimer(d - aday) } - err := jni.Do(a.jvm, func(env *jni.Env) error { + if err := a.pushNotify(service, title, msg); err != nil { + fatalErr(err) + } + return t +} + +func (a *App) pushNotify(service jni.Object, title, msg string) error { + return jni.Do(a.jvm, func(env *jni.Env) error { cls := jni.GetObjectClass(env, service) notify := jni.GetMethodID(env, cls, "notify", "(Ljava/lang/String;Ljava/lang/String;)V") jtitle := jni.JavaString(env, title) jmessage := jni.JavaString(env, msg) return jni.CallVoidMethod(env, service, notify, jni.Value(jtitle), jni.Value(jmessage)) }) - if err != nil { - fatalErr(err) - } - return t } func (a *App) notify(state BackendState) { @@ -638,15 +729,19 @@ func (a *App) updateState(act jni.Object, state *clientState) { state.browseURL = "" } - state.Peers = nil - netMap := state.backend.NetworkMap - if netMap == nil { - return + netmap := state.backend.NetworkMap + var ( + peers []*tailcfg.Node + myID tailcfg.UserID + ) + if netmap != nil { + peers = netmap.Peers + myID = netmap.User } // Split into sections. users := make(map[tailcfg.UserID]struct{}) - var peers []UIPeer - for _, p := range netMap.Peers { + var uiPeers []UIPeer + for _, p := range peers { if q := state.query; q != "" { // Filter peers according to search query. host := strings.ToLower(p.Hostinfo.Hostname) @@ -660,20 +755,19 @@ func (a *App) updateState(act jni.Object, state *clientState) { } } users[p.User] = struct{}{} - peers = append(peers, UIPeer{ + uiPeers = append(uiPeers, UIPeer{ Owner: p.User, Peer: p, }) } // Add section (user) headers. for u := range users { - name := netMap.UserProfiles[u].DisplayName + name := netmap.UserProfiles[u].DisplayName name = strings.ToUpper(name) - peers = append(peers, UIPeer{Owner: u, Name: name}) + uiPeers = append(uiPeers, UIPeer{Owner: u, Name: name}) } - myID := state.backend.NetworkMap.User - sort.Slice(peers, func(i, j int) bool { - lhs, rhs := peers[i], peers[j] + sort.Slice(uiPeers, func(i, j int) bool { + lhs, rhs := uiPeers[i], uiPeers[j] if lu, ru := lhs.Owner, rhs.Owner; ru != lu { // Sort own peers first. if lu == myID { @@ -696,7 +790,7 @@ func (a *App) updateState(act jni.Object, state *clientState) { rName := rp.DisplayName(rp.User == myID) return lName < rName || lName == rName && lp.ID < rp.ID }) - state.Peers = peers + state.Peers = uiPeers } func (a *App) prepareVPN(act jni.Object) error { @@ -729,6 +823,8 @@ func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, s requestBackend(e) case ConnectEvent: requestBackend(e) + case RouteAllEvent: + requestBackend(e) case CopyEvent: w.WriteClipboard(e.Text) case GoogleAuthEvent: diff --git a/cmd/tailscale/ui.go b/cmd/tailscale/ui.go index 719712c..7705e3c 100644 --- a/cmd/tailscale/ui.go +++ b/cmd/tailscale/ui.go @@ -27,8 +27,8 @@ import ( "golang.org/x/exp/shiny/materialdesign/icons" "tailscale.com/ipn" "tailscale.com/tailcfg" - "tailscale.com/types/netmap" + "eliasnaur.com/font/roboto/robotobold" "eliasnaur.com/font/roboto/robotoregular" _ "image/png" @@ -47,14 +47,25 @@ type UI struct { // webSigin is the button for the web-based sign-in flow. webSignin widget.Clickable - // googleSignin is the button for native Google Sign-in + // googleSignin is the button for native Google Sign-in. googleSignin widget.Clickable + // openExitDialog opens the exit node picker. + openExitDialog widget.Clickable + signinType signinType self widget.Clickable peers []widget.Clickable + // exitDialog is state for the exit node dialog. + exitDialog struct { + show bool + dismiss Dismiss + exits widget.Enum + list layout.List + } + intro struct { list layout.List start widget.Clickable @@ -68,6 +79,7 @@ type UI struct { copy widget.Clickable reauth widget.Clickable + exits widget.Clickable logout widget.Clickable } @@ -79,13 +91,12 @@ type UI struct { } icons struct { - search *widget.Icon - more *widget.Icon - logo paint.ImageOp - google paint.ImageOp + search *widget.Icon + more *widget.Icon + exitStatus *widget.Icon + logo paint.ImageOp + google paint.ImageOp } - - events []UIEvent } type signinType uint8 @@ -101,6 +112,12 @@ type UIPeer struct { Peer *tailcfg.Node } +// menuItem describes an item in a popup menu. +type menuItem struct { + title string + btn *widget.Clickable +} + const ( headerColor = 0x496495 infoColor = 0x3a517b @@ -131,6 +148,10 @@ func newUI(store *stateStore) (*UI, error) { if err != nil { return nil, err } + exitStatus, err := widget.NewIcon(icons.NavigationMenu) + if err != nil { + return nil, err + } logoData, err := tailscalePngBytes() if err != nil { return nil, err @@ -151,7 +172,14 @@ func newUI(store *stateStore) (*UI, error) { if err != nil { panic(fmt.Sprintf("failed to parse font: %v", err)) } - fonts := []text.FontFace{{Font: text.Font{Typeface: "Roboto"}, Face: face}} + faceBold, err := opentype.Parse(robotobold.TTF) + if err != nil { + panic(fmt.Sprintf("failed to parse font: %v", err)) + } + fonts := []text.FontFace{ + {Font: text.Font{Typeface: "Roboto"}, Face: face}, + {Font: text.Font{Typeface: "Roboto", Weight: text.Bold}, Face: faceBold}, + } ui := &UI{ theme: material.NewTheme(fonts), store: store, @@ -159,13 +187,16 @@ func newUI(store *stateStore) (*UI, error) { ui.intro.show, _ = store.ReadBool(keyShowIntro, true) ui.icons.search = searchIcon ui.icons.more = moreIcon + ui.icons.exitStatus = exitStatus ui.icons.logo = paint.NewImageOp(logo) ui.icons.google = paint.NewImageOp(google) ui.icons.more.Color = rgb(white) ui.icons.search.Color = mulAlpha(ui.theme.Palette.Fg, 0xbb) + ui.icons.exitStatus.Color = rgb(white) ui.root.Axis = layout.Vertical ui.intro.list.Axis = layout.Vertical ui.search.SingleLine = true + ui.exitDialog.list.Axis = layout.Vertical return ui, nil } @@ -183,14 +214,14 @@ func (ui *UI) onBack() bool { } func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent { - ui.events = nil + var events []UIEvent if ui.enabled.Changed() { - ui.events = append(ui.events, ConnectEvent{Enable: ui.enabled.Value}) + events = append(events, ConnectEvent{Enable: ui.enabled.Value}) } for _, e := range ui.search.Events() { if _, ok := e.(widget.ChangeEvent); ok { - ui.events = append(ui.events, SearchEvent{Query: ui.search.Text()}) + events = append(events, SearchEvent{Query: ui.search.Text()}) break } } @@ -199,36 +230,57 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat } netmap := state.backend.NetworkMap - var localName, localAddr string - var expiry time.Time + var ( + localName, localAddr string + expiry time.Time + userID tailcfg.UserID + exitID tailcfg.StableNodeID + ) if netmap != nil { + userID = netmap.User expiry = netmap.Expiry localName = netmap.SelfNode.DisplayName(false) if addrs := netmap.Addresses; len(addrs) > 0 { localAddr = addrs[0].IP.String() } } + if p := state.backend.Prefs; p != nil { + exitID = p.ExitNodeID + } + if d := &ui.exitDialog; d.show { + if newID := tailcfg.StableNodeID(d.exits.Value); newID != exitID { + d.show = false + events = append(events, RouteAllEvent{newID}) + } + } else { + d.exits.Value = string(exitID) + } if ui.googleSignin.Clicked() { ui.signinType = googleSignin - ui.events = append(ui.events, GoogleAuthEvent{}) + events = append(events, GoogleAuthEvent{}) } if ui.webSignin.Clicked() { ui.signinType = webSignin - ui.events = append(ui.events, WebAuthEvent{}) + events = append(events, WebAuthEvent{}) } if ui.menuClicked(&ui.menu.copy) && localAddr != "" { - ui.copyAddress(gtx, localAddr) + events = append(events, CopyEvent{Text: localAddr}) + ui.showCopied(gtx, localAddr) } if ui.menuClicked(&ui.menu.reauth) { - ui.events = append(ui.events, ReauthEvent{}) + events = append(events, ReauthEvent{}) + } + + if ui.menuClicked(&ui.menu.exits) || ui.openExitDialog.Clicked() { + ui.exitDialog.show = true } if ui.menuClicked(&ui.menu.logout) { - ui.events = append(ui.events, LogoutEvent{}) + events = append(events, LogoutEvent{}) } for len(ui.peers) < len(state.Peers) { @@ -238,7 +290,7 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat ui.peers = ui.peers[:max] } - const numHeaders = 5 + const numHeaders = 6 n := numHeaders + len(state.Peers) needsLogin := state.backend.State == ipn.NeedsLogin ui.root.Layout(gtx, n, func(gtx C, idx int) D { @@ -256,18 +308,24 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat if netmap == nil || state.backend.State < ipn.Stopped { return D{} } + for ui.self.Clicked() { + events = append(events, CopyEvent{Text: localAddr}) + ui.showCopied(gtx, localAddr) + } return ui.layoutLocal(gtx, sysIns, localName, localAddr) case 2: + return ui.layoutExitStatus(gtx, &state.backend) + case 3: if state.backend.State < ipn.Stopped { return D{} } return ui.layoutSearchbar(gtx, sysIns) - case 3: + case 4: if !needsLogin || state.backend.LostInternet { return D{} } return ui.layoutSignIn(gtx, &state.backend) - case 4: + case 5: if !state.backend.LostInternet { return D{} } @@ -280,24 +338,33 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat p := &state.Peers[pidx] if p.Peer == nil { name := p.Name - if p.Owner == netmap.User { + if p.Owner == userID { name = "MY DEVICES" } return ui.layoutSection(gtx, sysIns, name) } else { clk := &ui.peers[pidx] - return ui.layoutPeer(gtx, sysIns, p, netmap, clk) + if clk.Clicked() { + if addrs := p.Peer.Addresses; len(addrs) > 0 { + a := addrs[0].IP.String() + events = append(events, CopyEvent{Text: a}) + ui.showCopied(gtx, a) + } + } + return ui.layoutPeer(gtx, sysIns, p, userID, clk) } } }) }) + ui.layoutExitNodeDialog(gtx, sysIns, state.backend.Exits) + // Popup messages. ui.layoutMessage(gtx, sysIns) // 3-dots menu. if ui.menu.show { - ui.layoutMenu(gtx, sysIns, expiry) + ui.layoutMenu(gtx, sysIns, expiry, exitID != "" || len(state.backend.Exits) > 0) } // "Get started". @@ -309,7 +376,7 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat ui.layoutIntro(gtx, sysIns) } - return ui.events + return events } func (ui *UI) ShowMessage(msg string) { @@ -321,10 +388,11 @@ func (ui *UI) ShowMessage(msg string) { type Dismiss struct { } -func (d *Dismiss) Add(gtx layout.Context) { +func (d *Dismiss) Add(gtx layout.Context, color color.NRGBA) { defer op.Save(gtx.Ops).Load() pointer.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Add(gtx.Ops) pointer.InputOp{Tag: d, Types: pointer.Press}.Add(gtx.Ops) + paint.Fill(gtx.Ops, color) } func (d *Dismiss) Dismissed(gtx layout.Context) bool { @@ -338,6 +406,51 @@ func (d *Dismiss) Dismissed(gtx layout.Context) bool { return false } +func (ui *UI) layoutExitStatus(gtx layout.Context, state *BackendState) layout.Dimensions { + var bg color.NRGBA + var text string + switch state.ExitStatus { + case ExitNone: + return D{} + case ExitOffline: + text = "Exit node offline" + bg = rgb(0xc65835) + case ExitOnline: + text = "Using exit node" + bg = rgb(0x338b51) + } + paint.Fill(gtx.Ops, bg) + return material.Clickable(gtx, &ui.openExitDialog, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: unit.Dp(12), + Bottom: unit.Dp(12), + Right: unit.Dp(24), + Left: unit.Dp(24), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + lbl := material.Body2(ui.theme, text) + lbl.Color = rgb(white) + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + node := material.Body2(ui.theme, state.Exit.Label) + node.Color = argb(0x88ffffff) + return node.Layout(gtx) + }), + ) + }), + layout.Rigid(func(gtx C) D { + return ui.icons.exitStatus.Layout(gtx, unit.Dp(24)) + }), + ) + }) + }) +} + // layoutSignIn lays out the sign in button(s). func (ui *UI) layoutSignIn(gtx layout.Context, state *BackendState) layout.Dimensions { return layout.Inset{Top: unit.Dp(48), Left: unit.Dp(48), Right: unit.Dp(48)}.Layout(gtx, func(gtx C) D { @@ -509,9 +622,134 @@ func (ui *UI) menuClicked(btn *widget.Clickable) bool { return cl } +// layoutExitNodeDialog lays out the exit node selection dialog. If the user changed the node, +// true is returned along with the node. +func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []ExitNode) { + d := &ui.exitDialog + if d.dismiss.Dismissed(gtx) { + d.show = false + } + if !d.show { + return + } + d.dismiss.Add(gtx, argb(0x66000000)) + layout.Inset{ + Top: unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)), + Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)), + Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)), + Left: unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)), + }.Layout(gtx, func(gtx C) D { + return layout.Center.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Px(unit.Dp(250)) + gtx.Constraints.Max.X = gtx.Constraints.Min.X + return layoutDialog(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + // Header. + return layout.Inset{ + Top: unit.Dp(16), + Right: unit.Dp(20), + Left: unit.Dp(20), + Bottom: unit.Dp(16), + }.Layout(gtx, func(gtx C) D { + l := material.Body1(ui.theme, "Use exit node...") + l.Font.Weight = text.Bold + return l.Layout(gtx) + }) + }), + layout.Flexed(1, func(gtx C) D { + gtx.Constraints.Min.Y = 0 + // Add "none" exit node. + n := len(exits) + 1 + return d.list.Layout(gtx, n, func(gtx C, idx int) D { + switch idx { + case n - 1: + } + node := ExitNode{Label: "None", Online: true} + if idx >= 1 { + node = exits[idx-1] + } + lbl := node.Label + if !node.Online { + lbl = lbl + " (offline)" + } + btn := material.RadioButton(ui.theme, &d.exits, string(node.ID), lbl) + if !node.Online { + btn.Color = rgb(0xbbbbbb) + btn.IconColor = btn.Color + } + return layout.Inset{ + Right: unit.Dp(16), + Left: unit.Dp(16), + Bottom: unit.Dp(16), + }.Layout(gtx, btn.Layout) + }) + }), + ) + }) + }) + }) +} + +func layoutMenu(th *material.Theme, gtx layout.Context, items []menuItem, header layout.Widget) layout.Dimensions { + return layoutDialog(gtx, func(gtx C) D { + // Lay out menu items twice; once for + // measuring the widest item, once for actual layout. + var maxWidth int + var minWidth int + children := []layout.FlexChild{ + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: unit.Dp(16), + Right: unit.Dp(16), + Left: unit.Dp(16), + Bottom: unit.Dp(4), + }.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = minWidth + dims := header(gtx) + if w := dims.Size.X; w > maxWidth { + maxWidth = w + } + return dims + }) + }), + } + for i := 0; i < len(items); i++ { + it := &items[i] + children = append(children, layout.Rigid(func(gtx C) D { + return material.Clickable(gtx, it.btn, func(gtx C) D { + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = minWidth + dims := material.Body1(th, it.title).Layout(gtx) + if w := dims.Size.X; w > maxWidth { + maxWidth = w + } + return dims + }) + }) + })) + } + f := layout.Flex{Axis: layout.Vertical} + // First pass: record and discard operations + // and determine widest item. + m := op.Record(gtx.Ops) + f.Layout(gtx, children...) + m.Stop() + // Second pass: layout items with equal width. + minWidth = maxWidth + return f.Layout(gtx, children...) + }) +} + +func layoutDialog(gtx layout.Context, w layout.Widget) layout.Dimensions { + return widget.Border{Color: argb(0x33000000), CornerRadius: unit.Dp(2), Width: unit.Px(1)}.Layout(gtx, func(gtx C) D { + return Background{Color: rgb(0xfafafa), CornerRadius: unit.Dp(2)}.Layout(gtx, w) + }) +} + // layoutMenu lays out the menu activated by the 3 dots button. -func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.Time) { - ui.menu.dismiss.Add(gtx) +func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.Time, showExits bool) { + ui.menu.dismiss.Add(gtx, color.NRGBA{}) if ui.menu.dismiss.Dismissed(gtx) { ui.menu.show = false } @@ -520,75 +758,31 @@ func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.T Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(2)), }.Layout(gtx, func(gtx C) D { return layout.NE.Layout(gtx, func(gtx C) D { - return widget.Border{Color: argb(0x33000000), CornerRadius: unit.Dp(2), Width: unit.Px(1)}.Layout(gtx, func(gtx C) D { - return Background{Color: rgb(0xfafafa), CornerRadius: unit.Dp(2)}.Layout(gtx, 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. - m := op.Record(gtx.Ops) - f.Layout(gtx, children...) - m.Stop() - // Second pass: layout items with equal width. - minWidth = maxWidth - return f.Layout(gtx, children...) - }) + menu := &ui.menu + items := []menuItem{ + {title: "Copy My IP Address", btn: &menu.copy}, + } + if showExits { + items = append(items, menuItem{title: "Use exit node...", btn: &menu.exits}) + } + items = append(items, + menuItem{title: "Reauthenticate", btn: &menu.reauth}, + menuItem{title: "Log out", btn: &menu.logout}, + ) + return layoutMenu(ui.theme, gtx, items, func(gtx C) D { + var expiryStr string + const fmtStr = time.Stamp + switch { + case expiry.IsZero(): + expiryStr = "Expires: (never)" + case time.Now().After(expiry): + expiryStr = fmt.Sprintf("Expired: %s", expiry.Format(fmtStr)) + default: + expiryStr = fmt.Sprintf("Expires: %s", expiry.Format(fmtStr)) + } + l := material.Caption(ui.theme, expiryStr) + l.Color = rgb(0x8f8f8f) + return l.Layout(gtx) }) }) }) @@ -627,12 +821,7 @@ func (ui *UI) showMessage(gtx layout.Context, msg string) { // 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, netmap *netmap.NetworkMap, clk *widget.Clickable) layout.Dimensions { - for clk.Clicked() { - if addrs := p.Peer.Addresses; len(addrs) > 0 { - ui.copyAddress(gtx, addrs[0].IP.String()) - } - } +func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *UIPeer, user tailcfg.UserID, clk *widget.Clickable) layout.Dimensions { return material.Clickable(gtx, clk, func(gtx C) D { return layout.Inset{ Top: unit.Dp(8), @@ -644,7 +833,7 @@ func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *UIPeer, ne return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D { - name := p.Peer.DisplayName(p.Peer.User == netmap.User) + name := p.Peer.DisplayName(p.Peer.User == user) return material.H6(ui.theme, name).Layout(gtx) }) }), @@ -722,11 +911,9 @@ func (ui *UI) layoutTop(gtx layout.Context, sysIns system.Insets, state *Backend 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)) - }) - }) + btn := material.IconButton(ui.theme, &ui.menu.open, ui.icons.more) + btn.Background = color.NRGBA{} + return btn.Layout(gtx) }), ) }) @@ -750,17 +937,13 @@ func statusString(state ipn.State) string { } } -func (ui *UI) copyAddress(gtx layout.Context, addr string) { - ui.events = append(ui.events, CopyEvent{Text: addr}) +func (ui *UI) showCopied(gtx layout.Context, addr string) { ui.showMessage(gtx, fmt.Sprintf("Copied %s", addr)) } // layoutLocal lays out the information box about the local node's // name and IP address. func (ui *UI) layoutLocal(gtx layout.Context, sysIns system.Insets, host, addr string) layout.Dimensions { - 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.Metric, sysIns.Right, unit.Dp(8)),