cmd/systray: add a basic linux systray app
This adds a systray app for linux, similar to the apps for macOS and
windows. There are already a number of community-developed systray apps,
but most of them are either long abandoned, are built for a specific
desktop environment, or simply wrap the tailscale CLI.
This uses fyne.io/systray (a fork of github.com/getlantern/systray)
which uses newer D-Bus specifications to render the tray icon and menu.
This results in a pretty broad support for modern desktop environments.
This initial commit lacks a number of features like profile switching,
device listing, and exit node selection. This is really focused on the
application structure, the interaction with LocalAPI, and some system
integration pieces like the app icon, notifications, and the clipboard.
Updates #1708
Signed-off-by: Will Norris <will@tailscale.com>
2024-07-10 20:45:10 +00:00
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
//go:build cgo || !darwin
|
|
|
|
|
|
|
|
// The systray command is a minimal Tailscale systray application for Linux.
|
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"log"
|
|
|
|
"os"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"fyne.io/systray"
|
|
|
|
"github.com/atotto/clipboard"
|
|
|
|
dbus "github.com/godbus/dbus/v5"
|
|
|
|
"github.com/toqueteos/webbrowser"
|
|
|
|
"tailscale.com/client/tailscale"
|
|
|
|
"tailscale.com/ipn"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
localClient tailscale.LocalClient
|
|
|
|
chState chan ipn.State // tailscale state changes
|
|
|
|
|
|
|
|
appIcon *os.File
|
|
|
|
)
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
systray.Run(onReady, onExit)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Menu represents the systray menu, its items, and the current Tailscale state.
|
|
|
|
type Menu struct {
|
|
|
|
mu sync.Mutex // protects the entire Menu
|
|
|
|
status *ipnstate.Status
|
|
|
|
|
|
|
|
connect *systray.MenuItem
|
|
|
|
disconnect *systray.MenuItem
|
|
|
|
|
|
|
|
self *systray.MenuItem
|
|
|
|
more *systray.MenuItem
|
|
|
|
quit *systray.MenuItem
|
|
|
|
|
|
|
|
eventCancel func() // cancel eventLoop
|
|
|
|
}
|
|
|
|
|
|
|
|
func onReady() {
|
|
|
|
log.Printf("starting")
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
setAppIcon(disconnected)
|
|
|
|
|
|
|
|
// dbus wants a file path for notification icons, so copy to a temp file.
|
|
|
|
appIcon, _ = os.CreateTemp("", "tailscale-systray.png")
|
|
|
|
io.Copy(appIcon, connected.render())
|
|
|
|
|
|
|
|
chState = make(chan ipn.State, 1)
|
|
|
|
|
|
|
|
status, err := localClient.Status(ctx)
|
|
|
|
if err != nil {
|
|
|
|
log.Print(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
menu := new(Menu)
|
|
|
|
menu.rebuild(status)
|
|
|
|
|
|
|
|
go watchIPNBus(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
// rebuild the systray menu based on the current Tailscale state.
|
|
|
|
//
|
|
|
|
// We currently rebuild the entire menu because it is not easy to update the existing menu.
|
|
|
|
// You cannot iterate over the items in a menu, nor can you remove some items like separators.
|
|
|
|
// So for now we rebuild the whole thing, and can optimize this later if needed.
|
|
|
|
func (menu *Menu) rebuild(status *ipnstate.Status) {
|
|
|
|
menu.mu.Lock()
|
|
|
|
defer menu.mu.Unlock()
|
|
|
|
|
|
|
|
if menu.eventCancel != nil {
|
|
|
|
menu.eventCancel()
|
|
|
|
}
|
|
|
|
menu.status = status
|
|
|
|
systray.ResetMenu()
|
|
|
|
|
|
|
|
menu.connect = systray.AddMenuItem("Connect", "")
|
|
|
|
menu.disconnect = systray.AddMenuItem("Disconnect", "")
|
|
|
|
menu.disconnect.Hide()
|
|
|
|
systray.AddSeparator()
|
|
|
|
|
|
|
|
if status != nil && status.Self != nil {
|
|
|
|
title := fmt.Sprintf("This Device: %s (%s)", status.Self.HostName, status.Self.TailscaleIPs[0])
|
|
|
|
menu.self = systray.AddMenuItem(title, "")
|
|
|
|
}
|
|
|
|
systray.AddSeparator()
|
|
|
|
|
|
|
|
menu.more = systray.AddMenuItem("More settings", "")
|
|
|
|
menu.more.Enable()
|
|
|
|
|
|
|
|
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
|
|
|
|
menu.quit.Enable()
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
ctx, menu.eventCancel = context.WithCancel(ctx)
|
|
|
|
go menu.eventLoop(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
// eventLoop is the main event loop for handling click events on menu items
|
|
|
|
// and responding to Tailscale state changes.
|
|
|
|
// This method does not return until ctx.Done is closed.
|
|
|
|
func (menu *Menu) eventLoop(ctx context.Context) {
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case state := <-chState:
|
|
|
|
switch state {
|
|
|
|
case ipn.Running:
|
|
|
|
setAppIcon(loading)
|
|
|
|
status, err := localClient.Status(ctx)
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("error getting tailscale status: %v", err)
|
|
|
|
}
|
|
|
|
menu.rebuild(status)
|
|
|
|
setAppIcon(connected)
|
|
|
|
menu.connect.SetTitle("Connected")
|
|
|
|
menu.connect.Disable()
|
|
|
|
menu.disconnect.Show()
|
|
|
|
menu.disconnect.Enable()
|
|
|
|
case ipn.NoState, ipn.Stopped:
|
|
|
|
menu.connect.SetTitle("Connect")
|
|
|
|
menu.connect.Enable()
|
|
|
|
menu.disconnect.Hide()
|
|
|
|
setAppIcon(disconnected)
|
|
|
|
case ipn.Starting:
|
|
|
|
setAppIcon(loading)
|
|
|
|
}
|
|
|
|
case <-menu.connect.ClickedCh:
|
|
|
|
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
WantRunning: true,
|
|
|
|
},
|
|
|
|
WantRunningSet: true,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Print(err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
case <-menu.disconnect.ClickedCh:
|
|
|
|
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
WantRunning: false,
|
|
|
|
},
|
|
|
|
WantRunningSet: true,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("disconnecting: %v", err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
case <-menu.self.ClickedCh:
|
|
|
|
copyTailscaleIP(menu.status.Self)
|
|
|
|
|
|
|
|
case <-menu.more.ClickedCh:
|
|
|
|
webbrowser.Open("http://100.100.100.100/")
|
|
|
|
|
|
|
|
case <-menu.quit.ClickedCh:
|
|
|
|
systray.Quit()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
|
|
|
|
// This method does not return.
|
|
|
|
func watchIPNBus(ctx context.Context) {
|
2024-08-23 15:34:36 +00:00
|
|
|
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys)
|
cmd/systray: add a basic linux systray app
This adds a systray app for linux, similar to the apps for macOS and
windows. There are already a number of community-developed systray apps,
but most of them are either long abandoned, are built for a specific
desktop environment, or simply wrap the tailscale CLI.
This uses fyne.io/systray (a fork of github.com/getlantern/systray)
which uses newer D-Bus specifications to render the tray icon and menu.
This results in a pretty broad support for modern desktop environments.
This initial commit lacks a number of features like profile switching,
device listing, and exit node selection. This is really focused on the
application structure, the interaction with LocalAPI, and some system
integration pieces like the app icon, notifications, and the clipboard.
Updates #1708
Signed-off-by: Will Norris <will@tailscale.com>
2024-07-10 20:45:10 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Printf("watching ipn bus: %v", err)
|
|
|
|
}
|
|
|
|
defer watcher.Close()
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
default:
|
|
|
|
n, err := watcher.Next()
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("ipnbus error: %v", err)
|
|
|
|
}
|
|
|
|
if n.State != nil {
|
|
|
|
chState <- *n.State
|
|
|
|
log.Printf("new state: %v", n.State)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
|
|
|
|
// and sends a notification with the copied value.
|
|
|
|
func copyTailscaleIP(device *ipnstate.PeerStatus) {
|
|
|
|
if device == nil || len(device.TailscaleIPs) == 0 {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
name := strings.Split(device.DNSName, ".")[0]
|
|
|
|
ip := device.TailscaleIPs[0].String()
|
|
|
|
err := clipboard.WriteAll(ip)
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("clipboard error: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
|
|
|
|
}
|
|
|
|
|
|
|
|
// sendNotification sends a desktop notification with the given title and content.
|
|
|
|
func sendNotification(title, content string) {
|
|
|
|
conn, err := dbus.SessionBus()
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("dbus: %v", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
timeout := 3 * time.Second
|
|
|
|
obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
|
|
|
|
call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0),
|
|
|
|
appIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
|
|
|
|
if call.Err != nil {
|
|
|
|
log.Printf("dbus: %v", call.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func onExit() {
|
|
|
|
log.Printf("exiting")
|
|
|
|
os.Remove(appIcon.Name())
|
|
|
|
}
|