feat: swift dialog UI for setup experience (#22972)
> Related issues: #22383, #22384 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [x] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
@@ -19,6 +19,11 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode
|
||||
return nil, ctxerr.Wrap(ctx, err, "loading host by orbit node key")
|
||||
}
|
||||
|
||||
appCfg, err := svc.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting app config")
|
||||
}
|
||||
|
||||
// get the status of the bootstrap package deployment
|
||||
bootstrapPkg, err := svc.ds.GetHostMDMMacOSSetup(ctx, host.ID)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
@@ -95,6 +100,7 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode
|
||||
ConfigurationProfiles: cfgProfResults,
|
||||
AccountConfiguration: acctCfgResult,
|
||||
Software: make([]*fleet.SetupExperienceStatusResult, 0),
|
||||
OrgLogoURL: appCfg.OrgInfo.OrgLogoURLLightBackground,
|
||||
}
|
||||
for _, r := range res {
|
||||
if r.IsForScript() {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- Adds a UI for the Fleet setup experience to show users the status of software installs and script
|
||||
executions during macOS Setup Assistant.
|
||||
@@ -871,7 +871,7 @@ func main() {
|
||||
orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{
|
||||
UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval,
|
||||
}))
|
||||
setupExperiencer := setupexperience.NewSetupExperiencer(orbitClient)
|
||||
setupExperiencer := setupexperience.NewSetupExperiencer(orbitClient, c.String("root-dir"))
|
||||
orbitClient.RegisterConfigReceiver(setupExperiencer)
|
||||
orbitClient.RegisterConfigReceiver(update.ApplySwiftDialogDownloaderMiddleware(updateRunner))
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package setupexperience
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/swiftdialog"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/update"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const doneMessage = `### Setup is complete\n\nPlease contact your IT Administrator if there were any errors.`
|
||||
|
||||
// Client is the minimal interface needed to communicate with the Fleet server.
|
||||
type Client interface {
|
||||
GetSetupExperienceStatus() (*fleet.SetupExperienceStatusPayload, error)
|
||||
@@ -13,28 +22,265 @@ type Client interface {
|
||||
// SetupExperiencer is the type that manages the Fleet setup experience flow during macOS Setup
|
||||
// Assistant. It uses swiftDialog as a UI for showing the status of software installations and
|
||||
// script execution that are configured to run before the user has full access to the device.
|
||||
// If the setup experience is supposed to run, it will launch a single swiftDialog instance and then
|
||||
// update that instance based on the results from the /orbit/setup_experience/status endpoint.
|
||||
type SetupExperiencer struct {
|
||||
OrbitClient Client
|
||||
closeChan chan struct{}
|
||||
rootDirPath string
|
||||
// Note: this object is not safe for concurrent use. Since the SetupExperiencer is a singleton,
|
||||
// its Run method is called within a WaitGroup,
|
||||
// and no other parts of Orbit need access to this field (or any other parts of the
|
||||
// SetupExperiencer), it's OK to not protect this with a lock.
|
||||
sd *swiftdialog.SwiftDialog
|
||||
// Name of each step -> is that step done
|
||||
steps map[string]bool
|
||||
started bool
|
||||
}
|
||||
|
||||
func NewSetupExperiencer(client Client) *SetupExperiencer {
|
||||
return &SetupExperiencer{OrbitClient: client}
|
||||
func NewSetupExperiencer(client Client, rootDirPath string) *SetupExperiencer {
|
||||
return &SetupExperiencer{
|
||||
OrbitClient: client,
|
||||
closeChan: make(chan struct{}),
|
||||
steps: make(map[string]bool),
|
||||
rootDirPath: rootDirPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error {
|
||||
// We should only launch swiftDialog if we get the notification from Fleet.
|
||||
if !oc.Notifications.RunSetupExperience {
|
||||
log.Debug().Msg("skipping setup experience")
|
||||
return nil
|
||||
}
|
||||
|
||||
// poll the status endpoint
|
||||
_, err := s.OrbitClient.GetSetupExperienceStatus()
|
||||
_, binaryPath, _ := update.LocalTargetPaths(
|
||||
s.rootDirPath,
|
||||
"swiftDialog",
|
||||
update.SwiftDialogMacOSTarget,
|
||||
)
|
||||
|
||||
if _, err := os.Stat(binaryPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Poll the status endpoint. This also releases the device if we're done.
|
||||
payload, err := s.OrbitClient.GetSetupExperienceStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: fill this in!
|
||||
// If swiftDialog isn't up yet, then launch it
|
||||
if err := s.startSwiftDialog(binaryPath, payload.OrgLogoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Defer this so that s.started is only false the first time this function runs.
|
||||
defer func() { s.started = true }()
|
||||
|
||||
select {
|
||||
case <-s.closeChan:
|
||||
log.Debug().Str("receiver", "setup_experiencer").Msg("swiftDialog closed")
|
||||
return nil
|
||||
default:
|
||||
// ok
|
||||
}
|
||||
|
||||
// We're rendering the initial loading UI (shown while there are still profiles, bootstrap package,
|
||||
// and account configuration to verify) right off the bat, so we can just no-op if any of those
|
||||
// are not terminal
|
||||
|
||||
if payload.BootstrapPackage != nil {
|
||||
if payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageFailed && payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageInstalled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
s.steps["bootstrap"] = true
|
||||
|
||||
if anyProfilePending(payload.ConfigurationProfiles) {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.steps["config_profiles"] = true
|
||||
|
||||
if payload.AccountConfiguration != nil {
|
||||
if payload.AccountConfiguration.Status != fleet.MDMAppleStatusAcknowledged &&
|
||||
payload.AccountConfiguration.Status != fleet.MDMAppleStatusError &&
|
||||
payload.AccountConfiguration.Status != fleet.MDMAppleStatusCommandFormatError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
s.steps["account_config"] = true
|
||||
|
||||
// Now render the UI for the software and script.
|
||||
if len(payload.Software) > 0 || payload.Script != nil {
|
||||
var stepsDone int
|
||||
var prog uint
|
||||
var steps []*fleet.SetupExperienceStatusResult
|
||||
if len(payload.Software) > 0 {
|
||||
steps = payload.Software
|
||||
}
|
||||
|
||||
if payload.Script != nil {
|
||||
steps = append(steps, payload.Script)
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
item := resultToListItem(step)
|
||||
if _, ok := s.steps[step.Name]; ok {
|
||||
err = s.sd.UpdateListItemByTitle(item.Title, item.StatusText, item.Status)
|
||||
if err != nil {
|
||||
log.Info().Err(err).Msg("updating list item in setup experience UI")
|
||||
}
|
||||
} else {
|
||||
err = s.sd.AddListItem(item)
|
||||
if err != nil {
|
||||
log.Info().Err(err).Msg("adding list item in setup experience UI")
|
||||
}
|
||||
s.steps[step.Name] = false
|
||||
}
|
||||
|
||||
if step.Status == fleet.SetupExperienceStatusFailure || step.Status == fleet.SetupExperienceStatusSuccess {
|
||||
stepsDone++
|
||||
s.steps[step.Name] = true
|
||||
// The swiftDialog progress bar is out of 100
|
||||
for range int(float32(1) / float32(len(steps)) * 100) {
|
||||
prog++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = s.sd.UpdateProgress(prog); err != nil {
|
||||
log.Info().Err(err).Msg("updating progress bar in setup experience UI")
|
||||
}
|
||||
|
||||
if err := s.sd.ShowList(); err != nil {
|
||||
log.Info().Err(err).Msg("showing progress bar in setup experience UI")
|
||||
}
|
||||
|
||||
if err := s.sd.UpdateProgressText(fmt.Sprintf("%.0f%%", float32(stepsDone)/float32(len(steps))*100)); err != nil {
|
||||
log.Info().Err(err).Msg("updating progress text in setup experience UI")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// If we get here, we can render the "done" UI.
|
||||
|
||||
if s.allStepsDone() {
|
||||
if err := s.sd.SetMessage(doneMessage); err != nil {
|
||||
log.Info().Err(err).Msg("setting message in setup experience UI")
|
||||
}
|
||||
|
||||
if err := s.sd.CompleteProgress(); err != nil {
|
||||
log.Info().Err(err).Msg("completing progress bar in setup experience UI")
|
||||
}
|
||||
|
||||
if len(payload.Software) > 0 || payload.Script != nil {
|
||||
// need to call this because SetMessage removes the list from the view for some reason :(
|
||||
if err := s.sd.ShowList(); err != nil {
|
||||
log.Info().Err(err).Msg("showing list in setup experience UI")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.sd.UpdateProgressText("100%"); err != nil {
|
||||
log.Info().Err(err).Msg("updating progress text in setup experience UI")
|
||||
}
|
||||
|
||||
if err := s.sd.EnableButton1(true); err != nil {
|
||||
log.Info().Err(err).Msg("enabling close button in setup experience UI")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SetupExperiencer) allStepsDone() bool {
|
||||
for _, done := range s.steps {
|
||||
if !done {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func anyProfilePending(profiles []*fleet.SetupExperienceConfigurationProfileResult) bool {
|
||||
for _, p := range profiles {
|
||||
if p.Status == fleet.MDMDeliveryPending {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *SetupExperiencer) startSwiftDialog(binaryPath, orgLogo string) error {
|
||||
if s.started {
|
||||
return nil
|
||||
}
|
||||
|
||||
created := make(chan struct{})
|
||||
swiftDialog, err := swiftdialog.Create(context.Background(), binaryPath)
|
||||
if err != nil {
|
||||
return errors.New("creating swiftDialog instance: %w")
|
||||
}
|
||||
s.sd = swiftDialog
|
||||
go func() {
|
||||
initOpts := &swiftdialog.SwiftDialogOptions{
|
||||
Title: "none",
|
||||
Message: "### Setting up your Mac...\n\nYour Mac is being configured by your organization using Fleet. This process may take some time to complete. Please don't attempt to restart or shut down the computer unless prompted to do so.",
|
||||
Icon: orgLogo,
|
||||
IconSize: 40,
|
||||
MessageAlignment: swiftdialog.AlignmentCenter,
|
||||
CentreIcon: true,
|
||||
Height: "625",
|
||||
Big: true,
|
||||
ProgressText: "Configuring your device...",
|
||||
Button1Text: "Close",
|
||||
Button1Disabled: true,
|
||||
}
|
||||
|
||||
if err := s.sd.Start(context.Background(), initOpts); err != nil {
|
||||
log.Error().Err(err).Msg("starting swiftDialog instance")
|
||||
}
|
||||
|
||||
if err = s.sd.ShowProgress(); err != nil {
|
||||
log.Error().Err(err).Msg("setting initial setup experience progress")
|
||||
}
|
||||
|
||||
log.Debug().Msg("swiftDialog process started")
|
||||
created <- struct{}{}
|
||||
|
||||
if _, err = s.sd.Wait(); err != nil {
|
||||
log.Error().Err(err).Msg("swiftdialog.Wait failed")
|
||||
}
|
||||
|
||||
s.closeChan <- struct{}{}
|
||||
}()
|
||||
<-created
|
||||
return nil
|
||||
}
|
||||
|
||||
func resultToListItem(result *fleet.SetupExperienceStatusResult) swiftdialog.ListItem {
|
||||
statusText := "Pending"
|
||||
status := swiftdialog.StatusWait
|
||||
|
||||
switch result.Status {
|
||||
case fleet.SetupExperienceStatusFailure:
|
||||
status = swiftdialog.StatusFail
|
||||
statusText = "Failed"
|
||||
case fleet.SetupExperienceStatusSuccess:
|
||||
status = swiftdialog.StatusSuccess
|
||||
statusText = "Installed"
|
||||
if result.IsForScript() {
|
||||
statusText = "Ran"
|
||||
}
|
||||
}
|
||||
|
||||
return swiftdialog.ListItem{
|
||||
Title: result.Name,
|
||||
Status: status,
|
||||
StatusText: statusText,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type SwiftDialog struct {
|
||||
exitCode ExitCode
|
||||
exitErr error
|
||||
done chan struct{}
|
||||
binPath string
|
||||
}
|
||||
|
||||
type SwiftDialogExit struct {
|
||||
@@ -52,17 +53,12 @@ const (
|
||||
ExitFileNotFound ExitCode = 202
|
||||
)
|
||||
|
||||
func Create(ctx context.Context, swiftDialogBin string, options *SwiftDialogOptions) (*SwiftDialog, error) {
|
||||
func Create(ctx context.Context, swiftDialogBin string) (*SwiftDialog, error) {
|
||||
commandFile, err := os.CreateTemp("", "swiftDialogCommand")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
|
||||
if err := commandFile.Chmod(CommandFilePerms); err != nil {
|
||||
@@ -72,43 +68,55 @@ func Create(ctx context.Context, swiftDialogBin string, options *SwiftDialogOpti
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sd := &SwiftDialog{
|
||||
cancel: cancel,
|
||||
commandFile: commandFile,
|
||||
context: ctx,
|
||||
done: make(chan struct{}),
|
||||
binPath: swiftDialogBin,
|
||||
}
|
||||
|
||||
return sd, nil
|
||||
}
|
||||
|
||||
func (s *SwiftDialog) Start(ctx context.Context, opts *SwiftDialogOptions) error {
|
||||
jsonBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext( //nolint:gosec
|
||||
ctx,
|
||||
swiftDialogBin,
|
||||
s.binPath,
|
||||
"--jsonstring", string(jsonBytes),
|
||||
"--commandfile", commandFile.Name(),
|
||||
"--commandfile", s.commandFile.Name(),
|
||||
"--json",
|
||||
)
|
||||
|
||||
s.cmd = cmd
|
||||
|
||||
outBuf := &bytes.Buffer{}
|
||||
cmd.Stdout = outBuf
|
||||
|
||||
s.output = outBuf
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
cancel(errors.New("could not start swiftDialog"))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sd := &SwiftDialog{
|
||||
cancel: cancel,
|
||||
cmd: cmd,
|
||||
commandFile: commandFile,
|
||||
context: ctx,
|
||||
done: make(chan struct{}),
|
||||
output: outBuf,
|
||||
s.cancel(errors.New("could not start swiftDialog"))
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
errExit := &exec.ExitError{}
|
||||
if errors.As(err, &errExit) && strings.Contains(errExit.Error(), "exit status") {
|
||||
sd.exitCode = ExitCode(errExit.ExitCode())
|
||||
s.exitCode = ExitCode(errExit.ExitCode())
|
||||
} else {
|
||||
sd.exitErr = fmt.Errorf("waiting for swiftDialog: %w", err)
|
||||
s.exitErr = fmt.Errorf("waiting for swiftDialog: %w", err)
|
||||
}
|
||||
}
|
||||
close(sd.done)
|
||||
cancel(ErrWindowClosed)
|
||||
close(s.done)
|
||||
s.cancel(ErrWindowClosed)
|
||||
}()
|
||||
|
||||
// This sleep makes sure that SD is fully up and running and has access to the command file.
|
||||
@@ -116,7 +124,7 @@ func Create(ctx context.Context, swiftDialogBin string, options *SwiftDialogOpti
|
||||
// commands may be lost.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
return sd, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SwiftDialog) finished() {
|
||||
@@ -172,16 +180,27 @@ func (s *SwiftDialog) sendCommand(command, arg string) error {
|
||||
if err := s.context.Err(); err != nil {
|
||||
return fmt.Errorf("could not send command: %w", context.Cause(s.context))
|
||||
}
|
||||
|
||||
fullCommand := fmt.Sprintf("%s: %s", command, arg)
|
||||
|
||||
return s.writeCommand(fullCommand)
|
||||
}
|
||||
|
||||
func (s *SwiftDialog) sendMultiCommand(commands ...string) error {
|
||||
multiCommands := strings.Join(commands, "\n")
|
||||
return s.writeCommand(multiCommands)
|
||||
}
|
||||
|
||||
func (s *SwiftDialog) writeCommand(fullCommand string) error {
|
||||
// For some reason swiftDialog needs us to open and close the file
|
||||
// to detect a new command, just writing to the file doesn't cause
|
||||
// a change
|
||||
|
||||
commandFile, err := os.OpenFile(s.commandFile.Name(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, CommandFilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening command file for writing: %w", err)
|
||||
}
|
||||
|
||||
fullCommand := fmt.Sprintf("%s: %s", command, arg)
|
||||
|
||||
_, err = fmt.Fprintf(commandFile, "%s\n", fullCommand)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing command to file: %w", err)
|
||||
@@ -223,6 +242,11 @@ func (s *SwiftDialog) AppendMessage(text string) error {
|
||||
return s.sendCommand("message", fmt.Sprintf("+ %s", sanitize(text)))
|
||||
}
|
||||
|
||||
// SetMessageKeepListItems sets the message to the given string while preserving the current list items.
|
||||
func (s *SwiftDialog) SetMessageKeepListItems(message string) error {
|
||||
return s.sendMultiCommand(fmt.Sprintf("message: %s", sanitize(message)), "list: show")
|
||||
}
|
||||
|
||||
///////////
|
||||
// Image //
|
||||
///////////
|
||||
@@ -332,6 +356,11 @@ func (s *SwiftDialog) UpdateListItemByIndex(index uint, statusText string, statu
|
||||
return s.sendCommand("listitem", arg)
|
||||
}
|
||||
|
||||
// ShowList forces the list to render.
|
||||
func (s *SwiftDialog) ShowList() error {
|
||||
return s.sendCommand("list", "show")
|
||||
}
|
||||
|
||||
/////////////
|
||||
// Buttons //
|
||||
/////////////
|
||||
|
||||
@@ -673,13 +673,13 @@ func (u *Updater) initializeDirectories() error {
|
||||
}
|
||||
|
||||
func CanRun(rootDirPath, targetName string, targetInfo TargetInfo) bool {
|
||||
_, swiftDialogPath, _ := LocalTargetPaths(
|
||||
_, binaryPath, _ := LocalTargetPaths(
|
||||
rootDirPath,
|
||||
targetName,
|
||||
targetInfo,
|
||||
)
|
||||
|
||||
if _, err := os.Stat(swiftDialogPath); err != nil {
|
||||
if _, err := os.Stat(binaryPath); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ type SetupExperienceStatusPayload struct {
|
||||
BootstrapPackage *SetupExperienceBootstrapPackageResult `json:"bootstrap_package,omitempty"`
|
||||
ConfigurationProfiles []*SetupExperienceConfigurationProfileResult `json:"configuration_profiles,omitempty"`
|
||||
AccountConfiguration *SetupExperienceAccountConfigurationResult `json:"account_configuration,omitempty"`
|
||||
OrgLogoURL string `json:"org_logo_url"`
|
||||
}
|
||||
|
||||
func IsSetupExperienceSupported(hostPlatform string) bool {
|
||||
|
||||
Reference in New Issue
Block a user