diff --git a/orbit/pkg/dialog/dialog.go b/orbit/pkg/dialog/dialog.go new file mode 100644 index 0000000000..362c77b0b6 --- /dev/null +++ b/orbit/pkg/dialog/dialog.go @@ -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 +} diff --git a/orbit/pkg/execuser/execuser.go b/orbit/pkg/execuser/execuser.go index 5dc188ea99..5d4aaa353f 100644 --- a/orbit/pkg/execuser/execuser.go +++ b/orbit/pkg/execuser/execuser.go @@ -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) +} diff --git a/orbit/pkg/execuser/execuser_darwin.go b/orbit/pkg/execuser/execuser_darwin.go index 7902b2c761..ca92601ba9 100644 --- a/orbit/pkg/execuser/execuser_darwin.go +++ b/orbit/pkg/execuser/execuser_darwin.go @@ -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") +} diff --git a/orbit/pkg/execuser/execuser_linux.go b/orbit/pkg/execuser/execuser_linux.go index 3ed91d7a62..1e9614d01b 100644 --- a/orbit/pkg/execuser/execuser_linux.go +++ b/orbit/pkg/execuser/execuser_linux.go @@ -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 { diff --git a/orbit/pkg/execuser/execuser_windows.go b/orbit/pkg/execuser/execuser_windows.go index 90e274b7a3..f3bd58038d 100644 --- a/orbit/pkg/execuser/execuser_windows.go +++ b/orbit/pkg/execuser/execuser_windows.go @@ -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. diff --git a/orbit/pkg/zenity/zenity.go b/orbit/pkg/zenity/zenity.go new file mode 100644 index 0000000000..bd51d214f8 --- /dev/null +++ b/orbit/pkg/zenity/zenity.go @@ -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...) +} diff --git a/orbit/pkg/zenity/zenity_test.go b/orbit/pkg/zenity/zenity_test.go new file mode 100644 index 0000000000..5d57f52d91 --- /dev/null +++ b/orbit/pkg/zenity/zenity_test.go @@ -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) +} diff --git a/tools/dialog/main.go b/tools/dialog/main.go new file mode 100644 index 0000000000..23e46da66c --- /dev/null +++ b/tools/dialog/main.go @@ -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) + } +}