Use xmessage for Safe Desktop Prompts in Shell Scripts

A cron job that pops up a dialog and hopes for the best is a cron job that will confuse someone at 3am. xmessage fixes that: one command gets you a button, a predictable exit status, and, if you ask for it, the clicked label back on stdout. These examples follow the xmessage 1.0.6 interface documented by the installed x11-utils package. Budget 10 minutes if a display is already up; a headless box needs a decision about how to report the missing one before you go further.

Before you start

You need the xmessage executable and a working X display. Check both before touching anything else:

command -v xmessage
printf 'DISPLAY=%s\n' "\${DISPLAY:-unset}"
dpkg-query -W -f='\${Package} \${Version}\n' x11-utils

On the machine used for this guide, the package reports x11-utils 7.7+6build2, and its manpage identifies the program as xmessage 1.0.6. The DISPLAY value must point to an X server your user can actually access. An empty value is not a prompt problem to debug: it just means there is nowhere for the window to appear.

Checkpoint: Continue when command -v prints a path and DISPLAY is set. Nothing in this guide needs elevated privileges. Do not run sudo xmessage: root may not have permission to touch your desktop display at all.

1. Display a one-button message

Pass the message as one or more non-option arguments. Without -buttons, xmessage creates a single okay button with exit value 0:

xmessage -center 'Backup finished: /srv/example was copied.'

-center just asks for the centre of the screen; it is optional, so drop it if the window manager should place the window itself. The command blocks the terminal until someone clicks, then exits with that button's value.

Check the exit status immediately, before another command overwrites it:

xmessage -center 'Click OK, then inspect this status.'
status=$?
printf 'xmessage exited with status %s\n' "$status"

Click the default button and you should see xmessage exited with status 0. A message bigger than the window gets scroll bars; without an explicit size, xmessage grows to fit, capped at 70 percent of the screen dimensions by default.

2. Give each choice its own status

xmessage -center \
  -buttons 'Open:10,Later:20,Cancel:30' \
  -default Open \
  'The report is ready. What should happen next?'
status=$?
case "$status" in
  10) printf '%s\n' 'opening the report' ;;
  20) printf '%s\n' 'leaving the report for later' ;;
  30) printf '%s\n' 'cancelling' ;;
  1) printf '%s\n' 'xmessage could not open or display the prompt' >&2; exit 1 ;;
  *) printf 'unexpected xmessage status: %s\n' "$status" >&2; exit 1 ;;
esac

Buttons are numbered from the left, and an unassigned value defaults to 100 plus that button's number, which is exactly the kind of thing you do not want to rely on. Explicit values decouple your script from button order. -default Open makes Return activate that button; leave out -default and Return does nothing at all.

Safety boundary: Keep the case branches separate from the prompt itself. Never put a destructive command directly in a button label, and never assume that closing the window means approval. The documented error status is 1, so reserve it and handle it as a failure.

3. Print the chosen label when text is clearer

Add -print when the label itself is the useful value. It goes to standard output while the exit status still carries the configured number:

choice=$(xmessage -print \
  -buttons 'Yes:0,No:1' \
  -default No \
  'Send the report now?')
status=$?
if [ "$status" -eq 1 ] && [ "$choice" = 'No' ]; then
  printf '%s\n' 'not sending'
elif [ "$status" -eq 0 ] && [ "$choice" = 'Yes' ]; then
  printf '%s\n' 'sending'
else
  printf 'prompt failed: status=%s label=%s\n' "$status" "$choice" >&2
  exit 1
fi

Checking both values stops a partial or unexpected result being treated as consent. Quote your command substitutions and variables too: labels are user-visible text rather than shell syntax, but unquoted output can still split into multiple words.

4. Read a longer message from a file or pipe

Use -file once the message is too long for a readable command line. xmessage reads the file itself, and a filename of - means standard input:

xmessage -file /path/to/report.txt -buttons 'Close:0'
printf '%s\n' 'Service check passed.' | \
  xmessage -file - -center -buttons 'Close:0'

Choose either -file or command-line arguments, not both. There is nothing to undo here: xmessage only displays text and exits, it never creates or edits the input file.

5. Make automation fail safely

A prompt with no timeout waits forever, which is a bad look in an unattended job. Add -timeout and on expiry xmessage exits with status 0, the exact same status the default okay button uses:

xmessage -timeout 30 -buttons 'Continue:0,Stop:2' \
  -default Stop 'This prompt expires in 30 seconds.'
status=$?
case "$status" in
  0) printf '%s\n' 'continued or timed out' ;;
  2) printf '%s\n' 'stopped by the operator' ;;
  1) printf '%s\n' 'display failure' >&2; exit 1 ;;
  *) printf 'unexpected status: %s\n' "$status" >&2; exit 1 ;;
esac

If timeout and acceptance need to mean different things, give the acceptance button its own value and treat status 0 as a timeout only when the label was printed with -print. A missing X display still produces an error rather than a timeout, even in an unattended job. Recovery there is operational: run the job somewhere an authorised display exists, build a separate non-graphical path, or stop and report the failure.

Done means