diff --git a/orbit/pkg/swiftdialog/options.go b/orbit/pkg/swiftdialog/options.go new file mode 100644 index 0000000000..9a7a416481 --- /dev/null +++ b/orbit/pkg/swiftdialog/options.go @@ -0,0 +1,313 @@ +package swiftdialog + +type SwiftDialogOptions struct { + // Set the Dialog title + Title string `json:"title,omitempty"` + // Text to use as subtitle when sending a system notification + Subtitle string `json:"subtitle,omitempty"` + // Set the dialog message + Message string `json:"message,omitempty"` + // Configure a pre-set window style + Style Style `json:"style,omitempty"` + // Set the message alignment + MessageAlignment Alignment `json:"messagealignment,omitempty"` + // Set the message position + MessagePosition Position `json:"messageposition,omitempty"` + // Enable help button with content + HelpMessage string `json:"helpmessage,omitempty"` + // Set the dialog icon, accepts file path, url, or builtin + // See https://github.com/swiftDialog/swiftDialog/wiki/Customising-the-Icon + Icon string `json:"icon"` + // Set the dialog icon size + IconSize uint `json:"iconsize,omitempty"` + // Set the dialog icon transparancy + IconAlpha uint `json:"iconalpha,omitempty"` + // Set an image to display as an overlay to Icon, accepts file path or url + OverlayIcon string + // Enable banner image, accepts file path or url + BannerImage string `json:"bannerimage,omitempty"` + // Enable title within banner area + BannerTitle string `json:"bannertitle,omitempty"` + // Set text to display in banner area + BannerText string `json:"bannertext,omitempty"` + // Set the label for Button1 + Button1Text string `json:"button1text,omitempty"` + // Set the Button1 action, accepts url + Button1Action string `json:"button1action,omitempty"` + // Displays Button2 with text + Button2Text string `json:"button2text,omitempty"` + // Custom Actions For Button 2 Is Not Implemented + Button2Action string `json:"button2action,omitempty"` + // Displays info button with text + InfoButtonText string `json:"infobuttontext,omitempty"` + // Set the info button action, accepts URL + InfoButtonAction string `json:"infobuttonaction,omitempty"` + // Configure how the button area is displayed + ButtonStyle ButtonStyle `json:"buttonstyle,omitempty"` + // Select Lists and Radio Buttons + SelectItems []SelectItems `json:"selectitems,omitempty"` + // Lets you modify the title text of the dialog + TitleFont string `json:"titlefont,omitempty"` + // Set the message font of the dialog + MessageFont string `json:"messagefont,omitempty"` + // Enable a textfield with the specified label + TextField []TextField `json:"textfield,omitempty"` + // Enable a checkbox with the specified label + Checkbox []Checkbox `json:"checkbox,omitempty"` + // Change the appearance of checkboxes + CheckboxStyle CheckboxStyle `json:"checkboxstyle,omitempty"` + // Enable countdown timer (in seconds) + Timer uint `json:"timer,omitempty"` + // Enable interactive progress bar + Progress uint `json:"progress,omitempty"` + // Enable the progress text + ProgressText string `json:"progresstext,omitempty"` + // Display an image + Image []Image `json:"image,omitempty"` + // Set dialog window width + Width uint `json:"width,omitempty"` + // Set dialog window height + Height string `json:"height,omitempty"` + // Set a dialog background image, accepts file path + Background string `json:"background,omitempty"` + // Set background image transparancy + BackgroundAlpha uint `json:"bgalpha,omitempty"` + // Set background image position + BackgroundPosition FullPosition `json:"bgposition,omitempty"` + // Set background image fill type + BackgroundFill BackgroundFill `json:"bgfill,omitempty"` + // Enable background image scaling + BackgroundScale BackgroundFill `json:"bgscale,omitempty"` + // Set dialog window position + Position FullPosition `json:"position,omitempty"` + // Set dialog window position offset + PositionOffset uint `json:"positionoffset,omitempty"` + // Display a video, accepts file path or url + Video string `json:"video,omitempty"` + // Display a caption underneath a video + VideoCaption string `json:"videocaption,omitempty"` + // Enable a list item with the specified label + ListItem []ListItem `json:"listitem,omitempty"` + // Set list style + ListStyle ListStyle `json:"liststyle,omitempty"` + // Display in place of info button + InfoText string `json:"infotext,omitempty"` + // Display in info box + InfoBox string `json:"infobox,omitempty"` + // Set dialog quit key + QuitKey string `json:"quitkey,omitempty"` + // Display a web page, accepts url + WebContent string `json:"webcontent,omitempty"` + // Use the specified authentication key to allow dialog to launch + Key string `json:"key,omitempty"` + // Generate a SHA256 value + Checksum string `json:"checksum,omitempty"` + // Open a file and display the contents as it is being written, accepts file path + DisplayLog string `json:"displaylog,omitempty"` + // Change the order in which some items are displayed, comma separated list + ViewOrder string `json:"vieworder,omitempty"` + // Set the preferred window appearance + Appearance Appearance `json:"appearance,omitempty"` + // Disable Button1 + Button1Disabled bool `json:"button1disabled,omitempty"` + // Disable Button2 + Button2Disabled bool `json:"button2disabled,omitempty"` + // Displays Button2 + Button2 bool `json:"button2,omitempty"` + // Displays info button + InfoButton bool `json:"infobutton,omitempty"` + // Print version string + Version string `json:"version,omitempty"` + // Hides the icon from view + HideIcon bool `json:"hideicon,omitempty"` + // Set icon to be in the centre + CentreIcon bool `json:"centreicon,omitempty"` + // Hide countdown timer if enabled + HideTimerBar bool `json:"hidetimerbar,omitempty"` + // Enable video autoplay + Autoplay bool `json:"autoplay,omitempty"` + // Blur screen content behind dialog window + BlurScreen bool `json:"blurscreen,omitempty"` + // Send a system notification + Notification string `json:"notification,omitempty"` + // Enable dialog to be moveable + Moveable bool `json:"moveable,omitempty"` + // Enable dialog to be always positioned on top of other windows + OnTop bool `json:"ontop,omitempty"` + // Enable 25% decrease in default window size + Small bool `json:"small,omitempty"` + // Enable 25% increase in default window size + Big bool `json:"big,omitempty"` + // Enable full screen view + Fullscreen bool `json:"fullscreen,omitempty"` + // Quit when info button is selected + QuitonInfo bool `json:"quitoninfo,omitempty"` + // Enable mini mode + Mini bool `json:"mini,omitempty"` + // Enable presentation mode + Presentation bool `json:"presentation,omitempty"` + // Enables window buttons [close,min,max] + WindowButtons string `json:"windowbuttons,omitempty"` + // Enable the dialog window to be resizable + Resizable *bool `json:"resizable,omitempty"` + // Enable the dialog window to appear on all screens + ShowOnAllScreens *bool `json:"showonallscreens,omitempty"` + // Enable the dialog window to be shown at login + LoginWindow bool `json:"loginwindow,omitempty"` + // Hides the default behaviour of Return ↵ and Esc ⎋ keys + HideDefaultKeyboardAction bool `json:"hidedefaultkeyboardaction,omitempty"` +} + +type Style string + +const ( + StylePresentation Style = "presentation" + StyleMini Style = "mini" + StyleCentered Style = "centered" + StyleAlert Style = "alert" + StyleCaution Style = "caution" + StyleWarning Style = "warning" +) + +type Alignment string + +const ( + AlignmentLeft Alignment = "left" + AlignmentCenter Alignment = "center" + AlignmentRight Alignment = "right" +) + +type Position string + +const ( + PositionTop Position = "top" + PositionCenter Position = "center" + PositionBottom Position = "bottom" +) + +type ButtonStyle string + +const ( + ButtonStyleCenter ButtonStyle = "center" + ButtonStyleStack ButtonStyle = "stack" +) + +type Checkbox struct { + Label string `json:"label"` + Checked bool `json:"checked"` + Disabled bool `json:"disabled"` + Icon string `json:"icon,omitempty"` + EnableButton1 bool `json:"enableButton1,omitempty"` +} + +type Image struct { + // ImageName is a file path or url + ImageName string `json:"imagename"` + Caption string `json:"caption"` +} + +type FullPosition string + +const ( + FullPositionTopLeft FullPosition = "topleft" + FullPositionLeft FullPosition = "left" + FullPositionBottomLeft FullPosition = "bottomleft" + FullPositionTop FullPosition = "top" + FullPositionCenter FullPosition = "center" + FullPositionBottom FullPosition = "bottom" + FullPositionTopRight FullPosition = "topright" + FullPositionRight FullPosition = "right" + FullPositionBottomRight FullPosition = "bottomright" +) + +type BackgroundFill string + +const ( + BackgroundFillFill BackgroundFill = "fill" + BackgroundFillFit BackgroundFill = "fit" +) + +type ListStyle string + +const ( + ListStyleExpanded ListStyle = "expanded" + ListStyleCompact ListStyle = "compact" +) + +type Appearance string + +const ( + AppearanceDark Appearance = "dark" + AppearanceLight Appearance = "light" +) + +type ListItem struct { + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Status Status `json:"status,omitempty"` + StatusText string `json:"statustext,omitempty"` +} + +type Status string + +const ( + StatusNone Status = "" + StatusWait Status = "wait" + StatusSuccess Status = "success" + StatusFail Status = "fail" + StatusError Status = "error" + StatusPending Status = "pending" + StatusProgress Status = "progress" +) + +type TextField struct { + Title string `json:"title"` + Confirm bool `json:"confirm,omitempty"` + Editor bool `json:"editor,omitempty"` + FileSelect bool `json:"fileselect,omitempty"` + FileType string `json:"filetype,omitempty"` + Name string `json:"name,omitempty"` + Prompt string `json:"prompt,omitempty"` + Regex string `json:"regex,omitempty"` + RegexError string `json:"regexerror,omitempty"` + Required bool `json:"required,omitempty"` + Secure bool `json:"secure,omitempty"` + Value string `json:"value,omitempty"` +} + +type CheckboxStyle struct { + Style string `json:"style"` + Size CheckboxStyleSize `json:"size"` +} + +type CheckboxStyleStyle string + +const ( + CheckboxDefault CheckboxStyleStyle = "default" + CheckboxCheckbox CheckboxStyleStyle = "checkbox" + CheckboxSwitch CheckboxStyleStyle = "switch" +) + +type CheckboxStyleSize string + +const ( + CheckboxMini CheckboxStyleSize = "mini" + CheckboxSmall CheckboxStyleSize = "small" + CheckboxRegular CheckboxStyleSize = "regular" + CheckboxLarge CheckboxStyleSize = "large" +) + +type SelectItems struct { + Title string `json:"title"` + Values []string `json:"values"` + Default string `json:"default,omitempty"` + Style SelectItemsStyle `json:"style,omitempty"` + Required bool `json:"required,omitempty"` +} + +type SelectItemsStyle string + +const ( + SelectItemsStyleDropdown SelectItemsStyle = "" + SelectItemsStyleRadio SelectItemsStyle = "radio" +) diff --git a/orbit/pkg/swiftdialog/run.go b/orbit/pkg/swiftdialog/run.go new file mode 100644 index 0000000000..c8a698c50d --- /dev/null +++ b/orbit/pkg/swiftdialog/run.go @@ -0,0 +1,458 @@ +package swiftdialog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" +) + +// SwiftDialog really wants the command file to be mode 666 for some reason +// https://github.com/swiftDialog/swiftDialog/wiki/Gotchas +var CommandFilePerms = fs.FileMode(0666) + +var ErrKilled = errors.New("process killed") +var ErrWindowClosed = errors.New("window closed") + +type SwiftDialog struct { + cancel context.CancelCauseFunc + cmd *exec.Cmd + commandFile *os.File + context context.Context + output *bytes.Buffer + exitCode ExitCode + exitErr error + done chan struct{} +} + +type SwiftDialogExit struct { + ExitCode ExitCode + Output map[string]any +} + +type ExitCode int + +const ( + ExitButton1 ExitCode = 0 + ExitButton2 ExitCode = 2 + ExitInfoButton ExitCode = 3 + ExitTimer ExitCode = 4 + ExitQuitCommand ExitCode = 5 + ExitQuitKey ExitCode = 10 + ExitKeyAuthFailed ExitCode = 30 + ExitImageResourceNotFound ExitCode = 201 + ExitFileNotFound ExitCode = 202 +) + +func Create(ctx context.Context, swiftDialogBin string, options *SwiftDialogOptions) (*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 { + commandFile.Close() + os.Remove(commandFile.Name()) + cancel(errors.New("could not create command file")) + return nil, err + } + + cmd := exec.CommandContext( //nolint:gosec + ctx, + swiftDialogBin, + "--jsonstring", string(jsonBytes), + "--commandfile", commandFile.Name(), + "--json", + ) + + outBuf := &bytes.Buffer{} + cmd.Stdout = 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, + } + + 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()) + } else { + sd.exitErr = fmt.Errorf("waiting for swiftDialog: %w", err) + } + } + close(sd.done) + cancel(ErrWindowClosed) + }() + + return sd, nil +} + +func (s *SwiftDialog) finished() { + <-s.done +} + +func (s *SwiftDialog) Kill() error { + s.cancel(ErrKilled) + s.finished() + if err := s.cleanup(); err != nil { + return fmt.Errorf("Close cleaning up after swiftDialog: %w", err) + } + + return nil +} + +func (s *SwiftDialog) cleanup() error { + s.cancel(nil) + cmdFileName := s.commandFile.Name() + err := s.commandFile.Close() + if err != nil { + return fmt.Errorf("closing swiftDialog command file: %w", err) + } + err = os.Remove(cmdFileName) + if err != nil { + return fmt.Errorf("removing swiftDialog command file: %w", err) + } + + return nil +} + +func (s *SwiftDialog) Wait() (*SwiftDialogExit, error) { + s.finished() + + parsed := map[string]any{} + if s.output.Len() != 0 { + if err := json.Unmarshal(s.output.Bytes(), &parsed); err != nil { + return nil, fmt.Errorf("parsing swiftDialog output: %w", err) + } + } + + if err := s.cleanup(); err != nil { + return nil, fmt.Errorf("Wait cleaning up after swiftDialog: %w", err) + } + + return &SwiftDialogExit{ + ExitCode: s.exitCode, + Output: parsed, + }, s.exitErr +} + +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)) + } + // 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) + } + + err = commandFile.Close() + if err != nil { + return fmt.Errorf("closing command file: %w", err) + } + + return nil +} + +/////////// +// Title // +/////////// + +// Updates the dialog title +func (s *SwiftDialog) UpdateTitle(title string) error { + return s.sendCommand("title", title) +} + +// Hides the title area +func (s *SwiftDialog) HideTitle() error { + return s.sendCommand("title", "none") +} + +///////////// +// Message // +///////////// + +// Set the dialog messsage +func (s *SwiftDialog) SetMessage(text string) error { + return s.sendCommand("message", sanitize(text)) +} + +// Append to the dialog message +func (s *SwiftDialog) AppendMessage(text string) error { + return s.sendCommand("message", fmt.Sprintf("+ %s", sanitize(text))) +} + +/////////// +// Image // +/////////// + +// Displays the selected image +func (s *SwiftDialog) Image(pathOrUrl string) error { + return s.sendCommand("image", pathOrUrl) +} + +// Displays the specified text underneath any displayed image +func (s *SwiftDialog) SetImageCaption(caption string) error { + return s.sendCommand("imagecaption", caption) +} + +////////////// +// Progress // +////////////// + +// When Dialog is initiated with the Progress option, this will update the progress value +func (s *SwiftDialog) UpdateProgress(progress uint) error { + return s.sendCommand("progress", fmt.Sprintf("%d", progress)) +} + +// Increments the progress by one +func (s *SwiftDialog) IncrementProgress() error { + return s.sendCommand("progress", "increment") +} + +// Resets the progress bar to 0 +func (s *SwiftDialog) ResetProgress() error { + return s.sendCommand("progress", "reset") +} + +// Maxes out the progress bar +func (s *SwiftDialog) CompleteProgress() error { + return s.sendCommand("progress", "complete") +} + +// Hide the progress bar +func (s *SwiftDialog) HideProgress() error { + return s.sendCommand("progress", "hide") +} + +// Show the progress bar +func (s *SwiftDialog) ShowProgress() error { + return s.sendCommand("progress", "show") +} + +// Will update the label associated with the progress bar +func (s *SwiftDialog) UpdateProgressText(text string) error { + return s.sendCommand("progresstext", text) +} + +/////////// +// Lists // +/////////// + +// Create a list +func (s *SwiftDialog) SetList(items []string) error { + return s.sendCommand("list", strings.Join(items, ",")) +} + +// Clears the list and removes it from display +func (s *SwiftDialog) ClearList() error { + return s.sendCommand("list", "clear") +} + +// Add a new item to the end of the current list +func (s *SwiftDialog) AddListItem(item ListItem) error { + arg := fmt.Sprintf("add, title: %s", item.Title) + if item.Status != "" { + arg = fmt.Sprintf("%s, status: %s", arg, item.Status) + } + if item.StatusText != "" { + arg = fmt.Sprintf("%s, statustext: %s", arg, item.StatusText) + } + return s.sendCommand("listitem", arg) +} + +// Delete an item by name +func (s *SwiftDialog) DeleteListItemByTitle(title string) error { + return s.sendCommand("listitem", fmt.Sprintf("delete, title: %s", title)) +} + +// Delete an item by index number (starting at 0) +func (s *SwiftDialog) DeleteListItemByIndex(index uint) error { + return s.sendCommand("listitem", fmt.Sprintf("delete, index: %d", index)) +} + +// Update a list item by name +func (s *SwiftDialog) UpdateListItemByTitle(title, statusText string, status Status, progressPercent ...uint) error { + argStatus := string(status) + if len(progressPercent) == 1 && status == StatusProgress { + argStatus = fmt.Sprintf("progress, progress: %d", progressPercent[0]) + } + arg := fmt.Sprintf("title: %s, status: %s, statustext: %s", title, argStatus, statusText) + return s.sendCommand("listitem", arg) +} + +// Update a list item by index number (starting at 0) +func (s *SwiftDialog) UpdateListItemByIndex(index uint, statusText string, status Status, progressPercent ...uint) error { + argStatus := string(status) + if len(progressPercent) == 1 && status == StatusProgress { + argStatus = fmt.Sprintf("progress, progress: %d", progressPercent[0]) + } + arg := fmt.Sprintf("index: %d, status: %s, statustext: %s", index, argStatus, statusText) + return s.sendCommand("listitem", arg) +} + +///////////// +// Buttons // +///////////// + +// Enable or disable button 1 +func (s *SwiftDialog) EnableButton1(enable bool) error { + arg := "disable" + if enable { + arg = "enable" + } + return s.sendCommand("button1", arg) +} + +// Enable or disable button 2 +func (s *SwiftDialog) EnableButton2(enable bool) error { + arg := "disable" + if enable { + arg = "enable" + } + return s.sendCommand("button2", arg) +} + +// Changes the button 1 label +func (s *SwiftDialog) SetButton1Text(text string) error { + return s.sendCommand("button1text", text) +} + +// Changes the button 2 label +func (s *SwiftDialog) SetButton2Text(text string) error { + return s.sendCommand("button2text", text) +} + +// Changes the info button label +func (s *SwiftDialog) SetInfoButtonText(text string) error { + return s.sendCommand("infobuttontext", text) +} + +////////////// +// Info box // +////////////// + +// Update the content in the info box +func (s *SwiftDialog) SetInfoBoxText(text string) error { + return s.sendCommand("infobox", sanitize(text)) +} + +// Append to the conteit in the info box +func (s *SwiftDialog) AppendInfoBoxText(text string) error { + return s.sendCommand("infobox", fmt.Sprintf("+ %s", sanitize(text))) +} + +////////// +// Icon // +////////// + +// Changes the displayed icon +// See https://github.com/swiftDialog/swiftDialog/wiki/Customising-the-Icon +func (s *SwiftDialog) SetIconLocation(location string) error { + return s.sendCommand("icon", location) +} + +// Moves the icon being shown +func (s *SwiftDialog) SetIconAlignment(alignment Alignment) error { + return s.sendCommand("icon", string(alignment)) +} + +// Hide the icon +func (s *SwiftDialog) HideIcon() error { + return s.sendCommand("icon", "hide") +} + +// Changes the size of the displayed icon +func (s *SwiftDialog) SetIconSize(size uint) error { + return s.sendCommand("icon", fmt.Sprintf("size: %d", size)) +} + +//////////// +// Window // +//////////// + +// Changes the width of the window maintaining the current position +func (s *SwiftDialog) SetWindowWidth(width uint) error { + return s.sendCommand("width", fmt.Sprintf("%d", width)) +} + +// Changes the height of the window maintaining the current position +func (s *SwiftDialog) SetWindowHeight(width uint) error { + return s.sendCommand("height", fmt.Sprintf("%d", width)) +} + +// Changes the window position +func (s *SwiftDialog) SetWindowPosition(position FullPosition) error { + return s.sendCommand("position", string(position)) +} + +// Display content from the specified URL +func (s *SwiftDialog) SetWebContent(url string) error { + return s.sendCommand("webcontent", url) +} + +// Hide web content +func (s *SwiftDialog) HideWebContent() error { + return s.sendCommand("webcontent", "none") +} + +// Display a video from the specified path or URL +func (s *SwiftDialog) SetVideo(location string) error { + return s.sendCommand("video", location) +} + +// Enables or disables the blur window layer +func (s *SwiftDialog) BlurScreen(enable bool) error { + blur := "disable" + if enable { + blur = "enable" + } + return s.sendCommand("blurscreen", blur) +} + +// Activates the dialog window and brings it to the forground +func (s *SwiftDialog) Activate() error { + return s.sendCommand("activate", "") +} + +// Quits dialog with exit code 5 (ExitQuitCommand) +func (s *SwiftDialog) Quit() error { + return s.sendCommand("quit", "") +} + +func sanitize(text string) string { + return strings.ReplaceAll(text, "\n", "\\n") +}