zenity package for Linux (#23619)

This commit is contained in:
Tim Lee
2024-11-20 09:44:40 -07:00
committed by GitHub
parent d33d6f0bb2
commit 161da905b0
8 changed files with 664 additions and 20 deletions
+66
View File
@@ -0,0 +1,66 @@
package dialog
import (
"context"
"errors"
"time"
)
var (
// ErrCanceled is returned when the dialog is canceled by the cancel button.
ErrCanceled = errors.New("dialog canceled")
// ErrTimeout is returned when the dialog is automatically closed due to a timeout.
ErrTimeout = errors.New("dialog timed out")
// ErrUnknown is returned when an unknown error occurs.
ErrUnknown = errors.New("unknown error")
)
// Dialog represents a UI dialog that can be displayed to the end user
// on a host
type Dialog interface {
// ShowEntry displays a dialog that accepts end user input. It returns the entered
// text or errors ErrCanceled, ErrTimeout, or ErrUnknown.
ShowEntry(ctx context.Context, opts EntryOptions) ([]byte, error)
// ShowInfo displays a dialog that displays information. It returns an error if the dialog
// could not be displayed.
ShowInfo(ctx context.Context, opts InfoOptions) error
// Progress displays a dialog that shows progress. It waits until the
// context is cancelled.
ShowProgress(ctx context.Context, opts ProgressOptions) error
}
// EntryOptions represents options for a dialog that accepts end user input.
type EntryOptions struct {
// Title sets the title of the dialog.
Title string
// Text sets the text of the dialog.
Text string
// HideText hides the text entered by the user.
HideText bool
// TimeOut sets the time in seconds before the dialog is automatically closed.
TimeOut time.Duration
}
// InfoOptions represents options for a dialog that displays information.
type InfoOptions struct {
// Title sets the title of the dialog.
Title string
// Text sets the text of the dialog.
Text string
// Timeout sets the time in seconds before the dialog is automatically closed.
TimeOut time.Duration
}
// ProgressOptions represents options for a dialog that shows progress.
type ProgressOptions struct {
// Title sets the title of the dialog.
Title string
// Text sets the text of the dialog.
Text string
}
+26 -4
View File
@@ -2,6 +2,8 @@
// SYSTEM service on Windows) as the current login user.
package execuser
import "context"
type eopts struct {
env [][2]string
args [][2]string
@@ -19,10 +21,6 @@ func WithEnv(name, value string) Option {
}
// WithArg sets command line arguments for the application.
//
// TODO: for now CLI arguments are only used by the darwin
// implementation, just because it's the only platform that needs
// them.
func WithArg(name, value string) Option {
return func(a *eopts) {
a.args = append(a.args, [2]string{name, value})
@@ -40,3 +38,27 @@ func Run(path string, opts ...Option) (lastLogs string, err error) {
}
return run(path, o)
}
// RunWithOutput runs an application as the current login user and returns its output.
// It assumes the caller is running with high privileges (root on UNIX).
//
// It blocks until the child process exits.
// Non ExitError errors return with a -1 exitCode.
func RunWithOutput(path string, opts ...Option) (output []byte, exitCode int, err error) {
var o eopts
for _, fn := range opts {
fn(&o)
}
return runWithOutput(path, o)
}
// RunWithWait runs an application as the current login user and waits for it to finish
// or to be canceled by the context. Canceling the context will not return an error.
// It assumes the caller is running with high privileges (root on UNIX).
func RunWithWait(ctx context.Context, path string, opts ...Option) error {
var o eopts
for _, fn := range opts {
fn(&o)
}
return runWithWait(ctx, path, o)
}
+10
View File
@@ -1,6 +1,8 @@
package execuser
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -47,3 +49,11 @@ func run(path string, opts eopts) (lastLogs string, err error) {
}
return tw.String(), nil
}
func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) {
return nil, 0, errors.New("not implemented")
}
func runWithWait(ctx context.Context, path string, opts eopts) error {
return errors.New("not implemented")
}
+90 -16
View File
@@ -3,6 +3,7 @@ package execuser
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
@@ -18,9 +19,96 @@ import (
// run uses sudo to run the given path as login user.
func run(path string, opts eopts) (lastLogs string, err error) {
args, err := getUserAndDisplayArgs(path, opts)
if err != nil {
return "", fmt.Errorf("get args: %w", err)
}
args = append(args,
// Append the packaged libayatana-appindicator3 libraries path to LD_LIBRARY_PATH.
//
// Fleet Desktop doesn't use libayatana-appindicator3 since 1.18.3, but we need to
// keep this to support older versions of Fleet Desktop.
fmt.Sprintf("LD_LIBRARY_PATH=%s:%s", filepath.Dir(path), os.ExpandEnv("$LD_LIBRARY_PATH")),
path,
)
cmd := exec.Command("sudo", args...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
log.Printf("cmd=%s", cmd.String())
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("open path %q: %w", path, err)
}
return "", nil
}
// run uses sudo to run the given path as login user and waits for the process to finish.
func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) {
args, err := getUserAndDisplayArgs(path, opts)
if err != nil {
return nil, -1, fmt.Errorf("get args: %w", err)
}
args = append(args, path)
if len(opts.args) > 0 {
for _, arg := range opts.args {
args = append(args, arg[0], arg[1])
}
}
cmd := exec.Command("sudo", args...)
log.Printf("cmd=%s", cmd.String())
output, err = cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
return output, exitCode, fmt.Errorf("%q exited with code %d: %w", path, exitCode, err)
}
return output, -1, fmt.Errorf("%q error: %w", path, err)
}
return output, exitCode, nil
}
func runWithWait(ctx context.Context, path string, opts eopts) error {
args, err := getUserAndDisplayArgs(path, opts)
if err != nil {
return fmt.Errorf("get args: %w", err)
}
args = append(args, path)
if len(opts.args) > 0 {
for _, arg := range opts.args {
args = append(args, arg[0], arg[1])
}
}
cmd := exec.CommandContext(ctx, "sudo", args...)
log.Printf("cmd=%s", cmd.String())
if err := cmd.Start(); err != nil {
return fmt.Errorf("cmd start %q: %w", path, err)
}
if err := cmd.Wait(); err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return fmt.Errorf("cmd wait %q: %w", path, err)
}
return nil
}
func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) {
user, err := getLoginUID()
if err != nil {
return "", fmt.Errorf("get user: %w", err)
return nil, fmt.Errorf("get user: %w", err)
}
// TODO(lucas): Default to display :0 if user DISPLAY environment variable
@@ -68,23 +156,9 @@ func run(path string, opts eopts) (lastLogs string, err error) {
// This is required for Ubuntu 18, and not required for Ubuntu 21/22
// (because it's already part of the user).
fmt.Sprintf("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%d/bus", user.id),
// Append the packaged libayatana-appindicator3 libraries path to LD_LIBRARY_PATH.
//
// Fleet Desktop doesn't use libayatana-appindicator3 since 1.18.3, but we need to
// keep this to support older versions of Fleet Desktop.
fmt.Sprintf("LD_LIBRARY_PATH=%s:%s", filepath.Dir(path), os.ExpandEnv("$LD_LIBRARY_PATH")),
path,
)
cmd := exec.Command("sudo", args...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
log.Printf("cmd=%s", cmd.String())
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("open path %q: %w", path, err)
}
return "", nil
return args, nil
}
type user struct {
+9
View File
@@ -6,6 +6,7 @@ package execuser
// To view what was modified/added, you can use the execuser_windows_diff.sh script.
import (
"context"
"errors"
"fmt"
"os"
@@ -117,6 +118,14 @@ func run(path string, opts eopts) (lastLogs string, err error) {
return "", startProcessAsCurrentUser(path, "", "")
}
func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) {
return nil, 0, errors.New("not implemented")
}
func runWithWait(ctx context.Context, path string, opts eopts) error {
return errors.New("not implemented")
}
// getCurrentUserSessionId will attempt to resolve
// the session ID of the user currently active on
// the system.
+140
View File
@@ -0,0 +1,140 @@
package zenity
import (
"bytes"
"context"
"fmt"
"github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
"github.com/fleetdm/fleet/v4/orbit/pkg/execuser"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
)
type Zenity struct {
// cmdWithOutput can be set in tests to mock execution of the dialog.
cmdWithOutput func(ctx context.Context, args ...string) ([]byte, int, error)
// cmdWithWait can be set in tests to mock execution of the dialog.
cmdWithWait func(ctx context.Context, args ...string) error
}
// New creates a new Zenity dialog instance for zenity v4 on Linux.
// Zenity implements the Dialog interface.
func New() *Zenity {
return &Zenity{
cmdWithOutput: execCmdWithOutput,
cmdWithWait: execCmdWithWait,
}
}
// ShowEntry displays an dialog that accepts end user input. It returns the entered
// text or errors ErrCanceled, ErrTimeout, or ErrUnknown.
func (z *Zenity) ShowEntry(ctx context.Context, opts dialog.EntryOptions) ([]byte, error) {
args := []string{"--entry"}
if opts.Title != "" {
args = append(args, fmt.Sprintf("--title=%s", opts.Title))
}
if opts.Text != "" {
args = append(args, fmt.Sprintf("--text=%s", opts.Text))
}
if opts.HideText {
args = append(args, "--hide-text")
}
if opts.TimeOut > 0 {
args = append(args, fmt.Sprintf("--timeout=%d", int(opts.TimeOut.Seconds())))
}
output, statusCode, err := z.cmdWithOutput(ctx, args...)
if err != nil {
switch statusCode {
case 1:
return nil, ctxerr.Wrap(ctx, dialog.ErrCanceled)
case 5:
return nil, ctxerr.Wrap(ctx, dialog.ErrTimeout)
default:
return nil, ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error())
}
}
return output, nil
}
// ShowInfo displays an information dialog. It returns errors ErrTimeout or ErrUnknown.
func (z *Zenity) ShowInfo(ctx context.Context, opts dialog.InfoOptions) error {
args := []string{"--info"}
if opts.Title != "" {
args = append(args, fmt.Sprintf("--title=%s", opts.Title))
}
if opts.Text != "" {
args = append(args, fmt.Sprintf("--text=%s", opts.Text))
}
if opts.TimeOut > 0 {
args = append(args, fmt.Sprintf("--timeout=%d", int(opts.TimeOut.Seconds())))
}
_, statusCode, err := z.cmdWithOutput(ctx, args...)
if err != nil {
switch statusCode {
case 5:
return ctxerr.Wrap(ctx, dialog.ErrTimeout)
default:
return ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error())
}
}
return nil
}
// ShowProgress starts a Zenity progress dialog with the given options.
// This function is designed to block until the provided context is canceled.
// It is intended to be used within a separate goroutine to avoid blocking
// the main execution flow.
//
// If the context is already canceled, the function will return immediately.
//
// Use this function for cases where a progress dialog is needed to run
// alongside other operations, with explicit cancellation or termination.
func (z *Zenity) ShowProgress(ctx context.Context, opts dialog.ProgressOptions) error {
args := []string{"--progress"}
if opts.Title != "" {
args = append(args, fmt.Sprintf("--title=%s", opts.Title))
}
if opts.Text != "" {
args = append(args, fmt.Sprintf("--text=%s", opts.Text))
}
// --pulsate shows a pulsating progress bar
args = append(args, "--pulsate")
// --no-cancel disables the cancel button
args = append(args, "--no-cancel")
err := z.cmdWithWait(ctx, args...)
if err != nil {
return ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error())
}
return nil
}
func execCmdWithOutput(ctx context.Context, args ...string) ([]byte, int, error) {
var opts []execuser.Option
for _, arg := range args {
opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args
}
output, exitCode, err := execuser.RunWithOutput("zenity", opts...)
// Trim the newline from zenity output
output = bytes.TrimSuffix(output, []byte("\n"))
return output, exitCode, err
}
func execCmdWithWait(ctx context.Context, args ...string) error {
var opts []execuser.Option
for _, arg := range args {
opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args
}
return execuser.RunWithWait(ctx, "zenity", opts...)
}
+268
View File
@@ -0,0 +1,268 @@
package zenity
import (
"context"
"os/exec"
"testing"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
"github.com/stretchr/testify/require"
"github.com/tj/assert"
)
type mockExecCmd struct {
output []byte
exitCode int
capturedArgs []string
waitDuration time.Duration
}
// MockCommandContext simulates exec.CommandContext and captures arguments
func (m *mockExecCmd) runWithOutput(ctx context.Context, args ...string) ([]byte, int, error) {
m.capturedArgs = append(m.capturedArgs, args...)
if m.exitCode != 0 {
return nil, m.exitCode, &exec.ExitError{}
}
return m.output, m.exitCode, nil
}
func (m *mockExecCmd) runWithWait(ctx context.Context, args ...string) error {
m.capturedArgs = append(m.capturedArgs, args...)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(m.waitDuration):
}
return nil
}
func TestShowEntryArgs(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
opts dialog.EntryOptions
expectedArgs []string
}{
{
name: "Basic Entry",
opts: dialog.EntryOptions{
Title: "A Title",
Text: "Some text",
},
expectedArgs: []string{"--entry", "--title=A Title", "--text=Some text"},
},
{
name: "All Options",
opts: dialog.EntryOptions{
Title: "Another Title",
Text: "Some more text",
HideText: true,
TimeOut: 1 * time.Minute,
},
expectedArgs: []string{"--entry", "--title=Another Title", "--text=Some more text", "--hide-text", "--timeout=60"},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{
output: []byte("some output"),
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
}
output, err := z.ShowEntry(ctx, tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.expectedArgs, mock.capturedArgs)
assert.Equal(t, []byte("some output"), output)
})
}
}
func TestShowEntryError(t *testing.T) {
ctx := context.Background()
testcases := []struct {
name string
exitCode int
expectedErr error
}{
{
name: "Dialog Cancelled",
exitCode: 1,
expectedErr: dialog.ErrCanceled,
},
{
name: "Dialog Timed Out",
exitCode: 5,
expectedErr: dialog.ErrTimeout,
},
{
name: "Unknown Error",
exitCode: 99,
expectedErr: dialog.ErrUnknown,
},
}
for _, tt := range testcases {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{
exitCode: tt.exitCode,
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
}
output, err := z.ShowEntry(ctx, dialog.EntryOptions{})
require.ErrorIs(t, err, tt.expectedErr)
assert.Nil(t, output)
})
}
}
func TestShowEntrySuccess(t *testing.T) {
ctx := context.Background()
mock := &mockExecCmd{
output: []byte("some output"),
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
}
output, err := z.ShowEntry(ctx, dialog.EntryOptions{})
assert.NoError(t, err)
assert.Equal(t, []byte("some output"), output)
}
func TestShowInfoArgs(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
opts dialog.InfoOptions
expectedArgs []string
}{
{
name: "Basic Entry",
opts: dialog.InfoOptions{},
expectedArgs: []string{"--info"},
},
{
name: "All Options",
opts: dialog.InfoOptions{
Title: "Another Title",
Text: "Some more text",
TimeOut: 1 * time.Minute,
},
expectedArgs: []string{"--info", "--title=Another Title", "--text=Some more text", "--timeout=60"},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
}
err := z.ShowInfo(ctx, tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.expectedArgs, mock.capturedArgs)
})
}
}
func TestShowInfoError(t *testing.T) {
ctx := context.Background()
testcases := []struct {
name string
exitCode int
expectedErr error
}{
{
name: "Dialog Timed Out",
exitCode: 5,
expectedErr: dialog.ErrTimeout,
},
{
name: "Unknown Error",
exitCode: 99,
expectedErr: dialog.ErrUnknown,
},
}
for _, tt := range testcases {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{
exitCode: tt.exitCode,
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
}
err := z.ShowInfo(ctx, dialog.InfoOptions{})
require.ErrorIs(t, err, tt.expectedErr)
})
}
}
func TestProgressArgs(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
opts dialog.ProgressOptions
expectedArgs []string
}{
{
name: "Basic Entry",
opts: dialog.ProgressOptions{
Title: "A Title",
Text: "Some text",
},
expectedArgs: []string{"--progress", "--title=A Title", "--text=Some text", "--pulsate", "--no-cancel"},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{}
z := &Zenity{
cmdWithWait: mock.runWithWait,
}
err := z.ShowProgress(ctx, tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.expectedArgs, mock.capturedArgs)
})
}
}
func TestProgressKillOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
mock := &mockExecCmd{
waitDuration: 5 * time.Second,
}
z := &Zenity{
cmdWithWait: mock.runWithWait,
}
done := make(chan struct{})
start := time.Now()
go func() {
_ = z.ShowProgress(ctx, dialog.ProgressOptions{})
close(done)
}()
time.Sleep(100 * time.Millisecond)
cancel()
<-done
assert.True(t, time.Since(start) < 5*time.Second)
}
+55
View File
@@ -0,0 +1,55 @@
package main
// This is a tool to test the zenity package on Linux
// It will show an entry dialog, a progress dialog, and an info dialog
import (
"context"
"fmt"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
"github.com/fleetdm/fleet/v4/orbit/pkg/zenity"
)
func main() {
prompt := zenity.New()
ctx := context.Background()
output, err := prompt.ShowEntry(ctx, dialog.EntryOptions{
Title: "Zenity Test Entry Title",
Text: "Zenity Test Entry Text",
HideText: true,
TimeOut: 10 * time.Second,
})
if err != nil {
fmt.Println("Err ShowEntry")
panic(err)
}
ctx, cancelProgress := context.WithCancel(context.Background())
go func() {
err := prompt.ShowProgress(ctx, dialog.ProgressOptions{
Title: "Zenity Test Progress Title",
Text: "Zenity Test Progress Text",
})
if err != nil {
fmt.Println("Err ShowProgress")
panic(err)
}
}()
time.Sleep(2 * time.Second)
cancelProgress()
err = prompt.ShowInfo(ctx, dialog.InfoOptions{
Title: "Zenity Test Info Title",
Text: "Result: " + string(output),
TimeOut: 10 * time.Second,
})
if err != nil {
fmt.Println("Err ShowInfo")
panic(err)
}
}