client/systray: add installer for a freedesktop autostart file (#18767)
Adds freedesktop as an option for installing autostart desktop files for starting the systray application. Fixes #18766 Signed-off-by: Claus Lensbøl <claus@tailscale.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package freedesktop provides helpers for freedesktop systems.
|
||||||
|
package freedesktop
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const needsEscape = " \t\n\"'\\><~|&;$*?#()`"
|
||||||
|
|
||||||
|
var escaper = strings.NewReplacer(`"`, `\"`, "`", "\\`", `$`, `\$`, `\`, `\\`)
|
||||||
|
|
||||||
|
// Quote quotes according to the Desktop Entry Specification, as below:
|
||||||
|
//
|
||||||
|
// Arguments may be quoted in whole. If an argument contains a reserved
|
||||||
|
// character the argument must be quoted. The rules for quoting of arguments is
|
||||||
|
// also applicable to the executable name or path of the executable program as
|
||||||
|
// provided.
|
||||||
|
//
|
||||||
|
// Quoting must be done by enclosing the argument between double quotes and
|
||||||
|
// escaping the double quote character, backtick character ("`"), dollar sign
|
||||||
|
// ("$") and backslash character ("\") by preceding it with an additional
|
||||||
|
// backslash character. Implementations must undo quoting before expanding field
|
||||||
|
// codes and before passing the argument to the executable program. Reserved
|
||||||
|
// characters are space (" "), tab, newline, double quote, single quote ("'"),
|
||||||
|
// backslash character ("\"), greater-than sign (">"), less-than sign ("<"),
|
||||||
|
// tilde ("~"), vertical bar ("|"), ampersand ("&"), semicolon (";"), dollar
|
||||||
|
// sign ("$"), asterisk ("*"), question mark ("?"), hash mark ("#"), parenthesis
|
||||||
|
// ("(") and (")") and backtick character ("`").
|
||||||
|
func Quote(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return `""`
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(s, needsEscape) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`"`)
|
||||||
|
escaper.WriteString(&b, s)
|
||||||
|
b.WriteString(`"`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package freedesktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEscape(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, input, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no illegal chars",
|
||||||
|
input: "/home/user",
|
||||||
|
want: "/home/user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
want: "\"\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "space",
|
||||||
|
input: " ",
|
||||||
|
want: "\" \"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tab",
|
||||||
|
input: "\t",
|
||||||
|
want: "\"\t\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newline",
|
||||||
|
input: "\n",
|
||||||
|
want: "\"\n\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double quote",
|
||||||
|
input: "\"",
|
||||||
|
want: "\"\\\"\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single quote",
|
||||||
|
input: "'",
|
||||||
|
want: "\"'\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backslash",
|
||||||
|
input: "\\",
|
||||||
|
want: "\"\\\\\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "greater than",
|
||||||
|
input: ">",
|
||||||
|
want: "\">\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "less than",
|
||||||
|
input: "<",
|
||||||
|
want: "\"<\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde",
|
||||||
|
input: "~",
|
||||||
|
want: "\"~\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pipe",
|
||||||
|
input: "|",
|
||||||
|
want: "\"|\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ampersand",
|
||||||
|
input: "&",
|
||||||
|
want: "\"&\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "semicolon",
|
||||||
|
input: ";",
|
||||||
|
want: "\";\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dollar",
|
||||||
|
input: "$",
|
||||||
|
want: "\"\\$\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "asterisk",
|
||||||
|
input: "*",
|
||||||
|
want: "\"*\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "question mark",
|
||||||
|
input: "?",
|
||||||
|
want: "\"?\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hash",
|
||||||
|
input: "#",
|
||||||
|
want: "\"#\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "open paren",
|
||||||
|
input: "(",
|
||||||
|
want: "\"(\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "close paren",
|
||||||
|
input: ")",
|
||||||
|
want: "\")\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backtick",
|
||||||
|
input: "`",
|
||||||
|
want: "\"\\`\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "char without escape",
|
||||||
|
input: "/home/user\t",
|
||||||
|
want: "\"/home/user\t\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "char with escape",
|
||||||
|
input: "/home/user\\",
|
||||||
|
want: "\"/home/user\\\\\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all illegal chars",
|
||||||
|
input: "/home/user" + needsEscape,
|
||||||
|
want: "\"/home/user \t\n\\\"'\\\\><~|&;\\$*?#()\\`\"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := Quote(tt.input)
|
||||||
|
if strings.Compare(got, tt.want) != 0 {
|
||||||
|
t.Errorf("expected %s, got %s", tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,34 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"tailscale.com/client/freedesktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed tailscale-systray.service
|
//go:embed tailscale-systray.service
|
||||||
var embedSystemd string
|
var embedSystemd string
|
||||||
|
|
||||||
|
//go:embed tailscale-systray.desktop
|
||||||
|
var embedFreedesktop string
|
||||||
|
|
||||||
|
//go:embed tailscale.svg
|
||||||
|
var embedLogoSvg string
|
||||||
|
|
||||||
|
//go:embed tailscale.png
|
||||||
|
var embedLogoPng string
|
||||||
|
|
||||||
func InstallStartupScript(initSystem string) error {
|
func InstallStartupScript(initSystem string) error {
|
||||||
switch initSystem {
|
switch initSystem {
|
||||||
case "systemd":
|
case "systemd":
|
||||||
return installSystemd()
|
return installSystemd()
|
||||||
|
case "freedesktop":
|
||||||
|
return installFreedesktop()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported init system '%s'", initSystem)
|
return fmt.Errorf("unsupported init system '%s'", initSystem)
|
||||||
}
|
}
|
||||||
@@ -58,7 +72,7 @@ func installSystemd() error {
|
|||||||
|
|
||||||
systemdDir := filepath.Join(configDir, "systemd", "user")
|
systemdDir := filepath.Join(configDir, "systemd", "user")
|
||||||
if err := os.MkdirAll(systemdDir, 0o755); err != nil {
|
if err := os.MkdirAll(systemdDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed creating systemd uuser dir: %w", err)
|
return fmt.Errorf("failed creating systemd user dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceFile := filepath.Join(systemdDir, "tailscale-systray.service")
|
serviceFile := filepath.Join(systemdDir, "tailscale-systray.service")
|
||||||
@@ -74,3 +88,129 @@ func installSystemd() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installFreedesktop() error {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "tailscale-systray")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to make tmpDir: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Install icon, and use it if it works, and if not change to some generic
|
||||||
|
// network/vpn icon.
|
||||||
|
iconName := "tailscale"
|
||||||
|
if err := installIcon(tmpDir); err != nil {
|
||||||
|
iconName = "network-transmit"
|
||||||
|
fmt.Printf("unable to install icon, continuing without: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create desktop file in a tmp dir
|
||||||
|
desktopTmpPath := filepath.Join(tmpDir, "tailscale-systray.desktop")
|
||||||
|
if err := os.WriteFile(desktopTmpPath, []byte(embedFreedesktop),
|
||||||
|
0o0755); err != nil {
|
||||||
|
return fmt.Errorf("unable to create desktop file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure autostart dir exists and install the desktop file
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to locate user home: %w", err)
|
||||||
|
}
|
||||||
|
configDir = filepath.Join(homeDir, ".config")
|
||||||
|
}
|
||||||
|
|
||||||
|
autostartDir := filepath.Join(configDir, "autostart")
|
||||||
|
if err := os.MkdirAll(autostartDir, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed creating freedesktop autostart dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopCmd := exec.Command("desktop-file-install", "--dir", autostartDir,
|
||||||
|
desktopTmpPath)
|
||||||
|
if output, err := desktopCmd.Output(); err != nil {
|
||||||
|
return fmt.Errorf("unable to install desktop file: %w - %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the path to tailscale, just in case it's not where the example file
|
||||||
|
// has it placed, and replace that before writing the file.
|
||||||
|
tailscaleBin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find tailscale binary %w", err)
|
||||||
|
}
|
||||||
|
tailscaleBin = freedesktop.Quote(tailscaleBin)
|
||||||
|
|
||||||
|
// Make possible changes to the desktop file
|
||||||
|
runEdit := func(args ...string) error {
|
||||||
|
cmd := exec.Command("desktop-file-edit", args...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cmd: %s: %w\n%s", cmd.String(), err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
edits := [][]string{
|
||||||
|
{"--set-key=Exec", "--set-value=" + tailscaleBin + " systray"},
|
||||||
|
{"--set-key=TryExec", "--set-value=" + tailscaleBin},
|
||||||
|
{"--set-icon=" + iconName},
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
desktopFile := filepath.Join(autostartDir, "tailscale-systray.desktop")
|
||||||
|
for _, args := range edits {
|
||||||
|
args = append(args, desktopFile)
|
||||||
|
if err := runEdit(args...); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed changing autostart file, try rebooting: %w", errors.Join(errs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully installed freedesktop autostart service to: %s\n", desktopFile)
|
||||||
|
fmt.Println("The service will run upon logging in.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installIcon installs an icon using the freedesktop tools. SVG support
|
||||||
|
// is still on its way for some distros, notably missing on Ubuntu 25.10 as of
|
||||||
|
// 2026-02-19. Try to install both icons and let the DE decide from what is
|
||||||
|
// available.
|
||||||
|
// Reference: https://gitlab.freedesktop.org/xdg/xdg-utils/-/merge_requests/116
|
||||||
|
func installIcon(tmpDir string) error {
|
||||||
|
svgPath := filepath.Join(tmpDir, "tailscale.svg")
|
||||||
|
if err := os.WriteFile(svgPath, []byte(embedLogoSvg), 0o0644); err != nil {
|
||||||
|
return fmt.Errorf("unable to create svg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pngPath := filepath.Join(tmpDir, "tailscale.png")
|
||||||
|
if err := os.WriteFile(pngPath, []byte(embedLogoPng), 0o0644); err != nil {
|
||||||
|
return fmt.Errorf("unable to create png: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
installed := false
|
||||||
|
svgCmd := exec.Command("xdg-icon-resource", "install", "--size", "scalable",
|
||||||
|
"--novendor", svgPath, "tailscale")
|
||||||
|
if output, err := svgCmd.Output(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("unable to install svg: %s - %s", err, output))
|
||||||
|
} else {
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
pngCmd := exec.Command("xdg-icon-resource", "install", "--size", "512",
|
||||||
|
"--novendor", pngPath, "tailscale")
|
||||||
|
if output, err := pngCmd.Output(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("unable to install png: %s - %s", err, output))
|
||||||
|
} else {
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !installed {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Version=1.0
|
||||||
|
Name=Tailscale System Tray
|
||||||
|
Comment=Tailscale system tray applet for managing Tailscale
|
||||||
|
Exec=/usr/bin/tailscale systray
|
||||||
|
TryExec=/usr/bin/tailscale
|
||||||
|
Terminal=false
|
||||||
|
NoDisplay=true
|
||||||
|
StartupNotify=false
|
||||||
|
Icon=tailscale
|
||||||
|
Categories=Network;System;
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="252" height="252" viewBox="0 0 252 252" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="252" height="252" rx="63" fill="#1F1E1E"/>
|
||||||
|
<path d="M72 144C81.9411 144 90 135.941 90 126C90 116.059 81.9411 108 72 108C62.0589 108 54 116.059 54 126C54 135.941 62.0589 144 72 144Z" fill="white"/>
|
||||||
|
<path d="M126 144C135.941 144 144 135.941 144 126C144 116.059 135.941 108 126 108C116.059 108 108 116.059 108 126C108 135.941 116.059 144 126 144Z" fill="white"/>
|
||||||
|
<path d="M126 198C135.941 198 144 189.941 144 180C144 170.059 135.941 162 126 162C116.059 162 108 170.059 108 180C108 189.941 116.059 198 126 198Z" fill="white"/>
|
||||||
|
<path d="M180 144C189.941 144 198 135.941 198 126C198 116.059 189.941 108 180 108C170.059 108 162 116.059 162 126C162 135.941 170.059 144 180 144Z" fill="white"/>
|
||||||
|
<g opacity="0.4">
|
||||||
|
<path d="M72 198C81.9411 198 90 189.941 90 180C90 170.059 81.9411 162 72 162C62.0589 162 54 170.059 54 180C54 189.941 62.0589 198 72 198Z" fill="white"/>
|
||||||
|
<path d="M180 198C189.941 198 198 189.941 198 180C198 170.059 189.941 162 180 162C170.059 162 162 170.059 162 180C162 189.941 170.059 198 180 198Z" fill="white"/>
|
||||||
|
<path d="M72 90C81.9411 90 90 81.9411 90 72C90 62.0589 81.9411 54 72 54C62.0589 54 54 62.0589 54 72C54 81.9411 62.0589 90 72 90Z" fill="white"/>
|
||||||
|
<path d="M126 90C135.941 90 144 81.9411 144 72C144 62.0589 135.941 54 126 54C116.059 54 108 62.0589 108 72C108 81.9411 116.059 90 126 90Z" fill="white"/>
|
||||||
|
<path d="M180 90C189.941 90 198 81.9411 198 72C198 62.0589 189.941 54 180 54C170.059 54 162 62.0589 162 72C162 81.9411 170.059 90 180 90Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -33,7 +33,7 @@ func systrayConfigCmd() *ffcli.Command {
|
|||||||
FlagSet: (func() *flag.FlagSet {
|
FlagSet: (func() *flag.FlagSet {
|
||||||
fs := newFlagSet("systray")
|
fs := newFlagSet("systray")
|
||||||
fs.StringVar(&systrayArgs.initSystem, "enable-startup", "",
|
fs.StringVar(&systrayArgs.initSystem, "enable-startup", "",
|
||||||
"Install startup script for init system. Currently supported systems are [systemd].")
|
"Install startup script for init system. Currently supported systems are [systemd, freedesktop].")
|
||||||
return fs
|
return fs
|
||||||
})(),
|
})(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
|||||||
software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12
|
software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12
|
||||||
tailscale.com from tailscale.com/version
|
tailscale.com from tailscale.com/version
|
||||||
💣 tailscale.com/atomicfile from tailscale.com/cmd/tailscale/cli+
|
💣 tailscale.com/atomicfile from tailscale.com/cmd/tailscale/cli+
|
||||||
|
L tailscale.com/client/freedesktop from tailscale.com/client/systray
|
||||||
tailscale.com/client/local from tailscale.com/client/tailscale+
|
tailscale.com/client/local from tailscale.com/client/tailscale+
|
||||||
L tailscale.com/client/systray from tailscale.com/cmd/tailscale/cli
|
L tailscale.com/client/systray from tailscale.com/cmd/tailscale/cli
|
||||||
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale
|
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale
|
||||||
|
|||||||
Reference in New Issue
Block a user