From 2753aa1e2c1e00f1c6e81cccb66bb946b50427b8 Mon Sep 17 00:00:00 2001 From: mostlikelee Date: Tue, 19 Nov 2024 13:30:28 -0700 Subject: [PATCH] add progress dialog --- orbit/pkg/dialog/dialog.go | 23 ++++- orbit/pkg/execuser/execuser.go | 13 +++ orbit/pkg/execuser/execuser_darwin.go | 5 + orbit/pkg/execuser/execuser_linux.go | 126 +++++++++++-------------- orbit/pkg/execuser/execuser_windows.go | 5 + orbit/pkg/zenity/zenity.go | 49 ++++++++-- orbit/pkg/zenity/zenity_test.go | 92 ++++++++++++++++-- tools/dialog/main.go | 21 +++++ 8 files changed, 248 insertions(+), 86 deletions(-) diff --git a/orbit/pkg/dialog/dialog.go b/orbit/pkg/dialog/dialog.go index bd899b06dacb..ac7172aacfdd 100644 --- a/orbit/pkg/dialog/dialog.go +++ b/orbit/pkg/dialog/dialog.go @@ -1,8 +1,5 @@ package dialog -// Dialog represents a UI dialog that can be displayed to the end user -// on a host - import ( "context" "errors" @@ -18,6 +15,8 @@ var ( 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. @@ -25,6 +24,9 @@ type Dialog interface { // 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 returns a channel that can be used to + // end the dialog. + ShowProgress(ctx context.Context, opts ProgressOptions) chan struct{} } // EntryOptions represents options for a dialog that accepts end user input. @@ -53,3 +55,18 @@ type InfoOptions struct { // 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 + + // Pulsate sets the progress bar to pulsate. + Pulsate bool + + // NoCancel sets the dialog to grey out the cancel button. + NoCancel bool +} diff --git a/orbit/pkg/execuser/execuser.go b/orbit/pkg/execuser/execuser.go index 452870d1c56d..11e4d4a866f9 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 @@ -48,3 +50,14 @@ func RunWithOutput(path string, opts ...Option) (output []byte, exitCode int, er } 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. +// 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 73f073446c69..ca92601ba980 100644 --- a/orbit/pkg/execuser/execuser_darwin.go +++ b/orbit/pkg/execuser/execuser_darwin.go @@ -1,6 +1,7 @@ package execuser import ( + "context" "errors" "fmt" "io" @@ -52,3 +53,7 @@ func run(path string, opts eopts) (lastLogs string, err error) { 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 e2c0505f7bc7..dd45fa72184e 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,56 +19,12 @@ import ( // run uses sudo to run the given path as login user. func run(path string, opts eopts) (lastLogs string, err error) { - user, err := getLoginUID() + args, err := getUserAndDisplayArgs(path, opts) if err != nil { - return "", fmt.Errorf("get user: %w", err) + return "", fmt.Errorf("get args: %w", err) } - // TODO(lucas): Default to display :0 if user DISPLAY environment variable - // could not be found, revisit when working on multi-user/multi-session support. - // This assumes there's only one desktop session and belongs to the - // user returned in `getLoginUID'. - defaultDisplay := ":0" - - log.Info(). - Str("user", user.name). - Int64("id", user.id). - Msg("attempting to get user's DISPLAY") - - display, err := getUserDisplay(user.name, opts) - if err != nil { - log.Error(). - Str("user", user.name). - Int64("id", user.id). - Err(err). - Msgf("failed to get user's DISPLAY, using default %s", defaultDisplay) - display = defaultDisplay - } else if display == "" { - log.Warn(). - Str("user", user.name). - Int64("id", user.id). - Msgf("user's DISPLAY not found, using default %s", defaultDisplay) - display = defaultDisplay - } - - log.Info(). - Str("path", path). - Str("user", user.name). - Int64("id", user.id). - Str("display", display). - Msg("running sudo") - - args := argsForSudo(user, opts) - args = append(args, - "DISPLAY="+display, - // DBUS_SESSION_BUS_ADDRESS sets the location of the user login session bus. - // Required by the libayatana-appindicator3 library to display a tray icon - // on the desktop session. - // - // 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 @@ -89,9 +46,59 @@ func run(path string, opts eopts) (lastLogs string, err error) { // 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, 0, fmt.Errorf("get args: %w", err) + } + + if len(opts.args) > 0 { + for _, arg := range opts.args { + args = append(args, arg[0], arg[1]) + } + } + + args = append(args, path) + + 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 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) + + cmd := exec.CommandContext(ctx, "sudo", args...) + log.Printf("cmd=%s", cmd.String()) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("cmd %q: %w", path, err) + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("cmd %q: %w", path, err) + } + + return nil +} + +func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) { user, err := getLoginUID() if err != nil { - return output, exitCode, 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 @@ -139,32 +146,9 @@ func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err er // 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, ) - 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 error: %w", path, err) - } - - return output, exitCode, nil + return args, nil } type user struct { diff --git a/orbit/pkg/execuser/execuser_windows.go b/orbit/pkg/execuser/execuser_windows.go index 9cf7e9d33855..f3bd58038db8 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" @@ -121,6 +122,10 @@ func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err er 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 index 63af2ccd73ce..d594d1db0140 100644 --- a/orbit/pkg/zenity/zenity.go +++ b/orbit/pkg/zenity/zenity.go @@ -11,15 +11,18 @@ import ( ) type Zenity struct { - // execCmdFn can be set in tests to mock execution of the dialog. - execCmdFn func(ctx context.Context, args ...string) ([]byte, int, error) + // 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 } // NewZenity creates a new Zenity dialog instance for zenity v4 on Linux. // Zenity implements the Dialog interface. func New() *Zenity { return &Zenity{ - execCmdFn: execCmd, + cmdWithOutput: execCmdWithOutput, + cmdWithWait: execCmdWithWait, } } @@ -40,7 +43,7 @@ func (z *Zenity) ShowEntry(ctx context.Context, opts dialog.EntryOptions) ([]byt args = append(args, fmt.Sprintf("--timeout=%d", int(opts.TimeOut.Seconds()))) } - output, statusCode, err := z.execCmdFn(ctx, args...) + output, statusCode, err := z.cmdWithOutput(ctx, args...) if err != nil { switch statusCode { case 1: @@ -68,7 +71,7 @@ func (z *Zenity) ShowInfo(ctx context.Context, opts dialog.InfoOptions) error { args = append(args, fmt.Sprintf("--timeout=%d", int(opts.TimeOut.Seconds()))) } - _, statusCode, err := z.execCmdFn(ctx, args...) + _, statusCode, err := z.cmdWithOutput(ctx, args...) if err != nil { switch statusCode { case 5: @@ -81,7 +84,32 @@ func (z *Zenity) ShowInfo(ctx context.Context, opts dialog.InfoOptions) error { return nil } -func execCmd(ctx context.Context, args ...string) ([]byte, int, error) { +// ShowProgress displays a progress dialog. It returns a channel that can be used to +// end the dialog. +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)) + } + if opts.Pulsate { + args = append(args, "--pulsate") + } + if opts.NoCancel { + 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 @@ -94,3 +122,12 @@ func execCmd(ctx context.Context, args ...string) ([]byte, int, error) { 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 index 9312d4412219..d6437baa4203 100644 --- a/orbit/pkg/zenity/zenity_test.go +++ b/orbit/pkg/zenity/zenity_test.go @@ -15,10 +15,11 @@ type mockExecCmd struct { output []byte exitCode int capturedArgs []string + waitDuration time.Duration } // MockCommandContext simulates exec.CommandContext and captures arguments -func (m *mockExecCmd) run(ctx context.Context, args ...string) ([]byte, int, error) { +func (m *mockExecCmd) runWithOutput(ctx context.Context, args ...string) ([]byte, int, error) { m.capturedArgs = append(m.capturedArgs, args...) if m.exitCode != 0 { @@ -28,6 +29,19 @@ func (m *mockExecCmd) run(ctx context.Context, args ...string) ([]byte, int, err 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() @@ -62,7 +76,7 @@ func TestShowEntryArgs(t *testing.T) { output: []byte("some output"), } z := &Zenity{ - execCmdFn: mock.run, + cmdWithOutput: mock.runWithOutput, } output, err := z.ShowEntry(ctx, tt.opts) assert.NoError(t, err) @@ -103,7 +117,7 @@ func TestShowEntryError(t *testing.T) { exitCode: tt.exitCode, } z := &Zenity{ - execCmdFn: mock.run, + cmdWithOutput: mock.runWithOutput, } output, err := z.ShowEntry(ctx, dialog.EntryOptions{}) require.ErrorIs(t, err, tt.expectedErr) @@ -119,7 +133,7 @@ func TestShowEntrySuccess(t *testing.T) { output: []byte("some output"), } z := &Zenity{ - execCmdFn: mock.run, + cmdWithOutput: mock.runWithOutput, } output, err := z.ShowEntry(ctx, dialog.EntryOptions{}) assert.NoError(t, err) @@ -154,7 +168,7 @@ func TestShowInfoArgs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &mockExecCmd{} z := &Zenity{ - execCmdFn: mock.run, + cmdWithOutput: mock.runWithOutput, } err := z.ShowInfo(ctx, tt.opts) assert.NoError(t, err) @@ -189,10 +203,76 @@ func TestShowInfoError(t *testing.T) { exitCode: tt.exitCode, } z := &Zenity{ - execCmdFn: mock.run, + 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"}, + }, + { + name: "All Options", + opts: dialog.ProgressOptions{ + Title: "Another Title", + Text: "Some more text", + Pulsate: true, + NoCancel: true, + }, + expectedArgs: []string{"--progress", "--title=Another Title", "--text=Some more 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 index 1117c2b2b262..0d88a2004e8a 100644 --- a/tools/dialog/main.go +++ b/tools/dialog/main.go @@ -1,5 +1,8 @@ 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" @@ -24,6 +27,24 @@ func main() { panic(err) } + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + err := prompt.ShowProgress(ctx, dialog.ProgressOptions{ + Title: "Zenity Test Progress Title", + Text: "Zenity Test Progress Text", + Pulsate: true, + NoCancel: true, + }) + if err != nil { + fmt.Println("Err ShowProgress") + panic(err) + } + }() + + time.Sleep(2 * time.Second) + cancel() + err = prompt.ShowInfo(ctx, dialog.InfoOptions{ Title: "Zenity Test Info Title", Text: "Result: " + string(output),