Qube-specific workspaces

Motivation

If you are a heavy user of the virtual workspaces feature offered by most window managers, it may have occured to you that it would be nice if each qube had its own dedicated workspace. This guide describes one way to achieve that.

Warnings

This guide is provided without warranty of any kind; follow it at your own risk only. While it is provided in good faith, you have no reason to trust it without thorougly checking the contents of the scripts.

Carefully review the provided scripts and only run them if you fully understand them. Despite my best efforts, they could contain bugs or introduce unforeseen vulnerabilities.

This guide is not suitable for beginners. It involves installing custom scripts in dom0, which is generally not advised. These scripts may need to be customized to fit your needs. If you’re unfamiliar with the command line, Qubes OS, or XFCE (configuring desktop icons, workspaces and keyboard shortcuts), you should not attempt to follow this guide. If something goes wrong, you will probably be left with a broken desktop environment with misbehaving windows and missing icons, potentially worse.

Exercise caution and consider backing up your system before proceeding.

Goal

Have dedicated workspaces for selected qubes with qube-specific icons, windows and keyboard shortcuts.

  • One workspace for dom0.
  • Workspaces for specific qubes.
  • Workspaces for specific colors (also called labels) of qubes.
  • An “other” workspace for qubes that don’t match any of the criteria above.

Example workspace list:

  1. dom0
  2. personal
  3. work
  4. green
  5. yellow
  6. red
  7. other

The resulting user experience

  • Desktop icons are only shown on specific workspaces based on the qube that they belong to. The workspace is selected by the first rule that matches:

    • If the icon does not come from a .desktop file, it is ignored (it shows up on all workspaces).
    • If the icon belongs to dom0, it is shown on the workspace for dom0.
    • Otherwise a workspace named after the qube is tried, e.g., “personal”.
    • A workspace named after the color (a.k.a. label) of the qube is tried next, e.g., “yellow”.
    • A workspace named “other” is used as the fall-back option.
  • Windows are automatically moved to a specific workspace based on the qube that they belong to. The workspace is selected by the first rule that matches:

    • Windows for dom0 are allowed on all workspaces as they are often needed for all qubes and it would be too cumbersome to always switch to the dedicated workspace.
    • Otherwise a workspace named after the qube is tried, e.g., “personal”.
    • A workspace named after the color (a.k.a. label) of the qube is tried next, e.g., “yellow”.
    • A workspace named “other” is used as the fall-back option.
  • Keyboard shortcuts can be set up to switch to a new or existing instance of an application in the qube corresponding to the active workspace. For example, pressing ctrl-alt-t on the personal workspace can activate (launch or switch to) a Terminal in the personal qube. The application launcher (alt-f2) can also be set up to run in the qube corresponding to the current workspace and therefore launch applications there.

    • If the current workspace is named after a qube (or dom0), the command is launched in that qube (or dom0)
    • Other workspaces do not uniqely identify a qube and thus can not be used for workspace-specific shortcuts. This includes workspaces named after a color (a.k.a. label), the “other” workspace and arbitrarily named workspaces.

Requirements

  • Qubes 4.2
  • XFCE as the window manager
    • the option to hide desktop icons for hidden files being turned on (which is the default)
  • English locale.

Other configurations may be made to work too, but this is the one that I used.

The scripts

manage-workspaces
#! /bin/bash

# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

shopt -s nullglob

# Allows some basic localization/customization if desired
DOM0=dom0
OTHER=other

# In `xprop` terminology, workspaces are called desktops and reflecting this,
# the script also calls them desktops or desks for short.

# Get the list of all desktops.
get_desk_names() {
  # This assumes that the names of desktops do not contain quotes or commas.
  xprop -root -notype | grep '^_NET_DESKTOP_NAMES = ' | cut -d ' ' -f 3- | tr -d '"' | sed 's/, /\n/g'
}

# Check whether the passed in string is the name a desktop.
is_valid_desk_name() {
  printf "%s\n" "${desk_names[@]}" | grep --silent --fixed-strings --line-regexp "$1"
}

# Determine which desktop to use for a given qube.
get_desk_name_for_qube() {
  local qube_name="$1"
  if $(is_valid_desk_name "$qube_name")
  then
    # If the qube name is a valid desktop name, use that.
    printf "%s" "$qube_name"
    return
  fi
  local qube_label="${qube_labels[$qube_name]}"
  if $(is_valid_desk_name "$qube_label")
  then
    # If the qube's label is a valid desktop name, use that.
    printf "%s" "$qube_label"
    return
  fi
  if $(is_valid_desk_name "$OTHER")
  then
    # If there is an "other" desktop, use that.
    printf "%s" "$OTHER"
  fi
}

# Determine which desktop to use for a given launcher.
get_desk_name_for_launcher() {
  local launcher="$1"
  if [[ "$launcher" != *.desktop ]]
  then
    # If the launcher is not a .desktop file, ignore it. (It will shows up on all workspaces.)
    return
  fi
  # Get qube name from launcher.
  qube_name=$(grep X-Qubes-VmName= "$launcher" | cut -d = -f 2)
  if [ -z "$qube_name" ]
  then
    # If there is no qube name in the launcher, then it is for dom0.
    qube_name="$DOM0"
  fi
  get_desk_name_for_qube "$qube_name"
}

# Determine which desktop to use for a given window.
get_desk_name_for_window() {
  local window_id="$1"
  # This assumes that no qubes have a quote in their name
  local qube_name=$(xprop -id "$1" -notype | grep '^_QUBES_VMNAME = "' | tr -d '"' | cut -d ' ' -f 3-)
  if [ -z "$qube_name" ]
  then
    # Allow dom0 windows on all desktops
    return
  fi
  get_desk_name_for_qube "$qube_name"
}

# Move a window to the desktop it belongs to.
organize_window() {
  local window_id="$1"
  local desk_name=$(get_desk_name_for_window "$window_id")
  # Find the number of the desktop with the given name.
  local desk_num=$(get_desk_names | grep --fixed-strings --line-regexp --line-number "$desk_name" | cut -d : -f 1)
  if [ "$desk_num" ]
  then
    wmctrl -i -r "$window_id" -t "$((desk_num - 1))"
  fi
}

# Hide a file by adding a dot to beginning of the filename.
hide_file() {
  dir=$(dirname "$1")
  file=$(basename "$1")
  if [[ "$file" != .* ]]
  then
    mv "$dir/$file" "$dir/.$file"
  fi
}

# Unhide a file by removing the dot at the beginning of the filename.
unhide_file() {
  dir=$(dirname "$1")
  file=$(basename "$1")
  if [[ "$file" == .* ]]
  then
    mv "$dir/$file" "$dir/${file#.}"
  fi
}

# Process changes in the window list or the current desktop.
xprop -root -spy -notype | stdbuf -oL grep '^_NET_CURRENT_DESKTOP = \|^_NET_CLIENT_LIST: window id # ' | \
while read -r line
do
  # Reread desk names and qube labels in case they were changed (very unlikely but can happen)
  readarray -t desk_names <<<$(get_desk_names)
  declare -A qube_labels
  while IFS='|'  read -r name label
  do
    qube_labels["$name"]="$label"
  done <<< $(qvm-ls -O NAME,LABEL --raw-data)
  case "$line" in
    '_NET_CLIENT_LIST: window id # '*)
      # Window list change, process all windows (in reverse order because it is
      # most likely that the last window needs to be moved).
      printf "%s\n" "$line" | \
        sed -e 's/_NET_CLIENT_LIST: window id # //' -e 's/, /\n/g' | \
        tac | \
      while read -r win_id
      do
        organize_window "$win_id"
      done
      ;;
    '_NET_CURRENT_DESKTOP = '*)
      # Current desktop change.
      desk_num=$(printf "%s" "$line" | cut -d ' ' -f 3)
      # Process visible launchers.
      for launcher in ~/Desktop/*
      do
        desk_name_for_launcher=$(get_desk_name_for_launcher "$launcher")
        # Hide those that don't belong on the current desktop.
        if [ "$desk_name_for_launcher" -a "$desk_name_for_launcher" != "${desk_names[$desk_num]}" ]
        then
          hide_file "$launcher"
        fi
      done
      # Process hidden launchers.
      for launcher in ~/Desktop/.*
      do
        desk_name_for_launcher=$(get_desk_name_for_launcher "$launcher")
        # Show those that belong on the current desktop.
        if [ "$desk_name_for_launcher" = "${desk_names[$desk_num]}" ]
        then
          unhide_file "$launcher"
        fi
      done
      ;;
  esac
done
switch-to-or-run-in-workspace
#! /bin/bash

# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

shopt -s nullglob
set -o pipefail

# Allows some basic localization/customization if desired
DOM0=dom0

target_class="$1"
shift

if [ -z "$1" ]
then
  cat >&2 << EOF
Usage: $0 {target-class} {target-command}
Example: $0 org.mozilla.firefox firefox

The window class and the command both need to be specified, because one can not
be determined from the other. You can get the window class by launching the
application then finding the corresponding window in the output of the
"wmctrl -xl" command.
EOF
  exit 1
fi

desk_num=$(xprop -root -notype | grep '^_NET_CURRENT_DESKTOP = ' | cut -d ' ' -f 3)
# This assumes that the names of desktops do not contain quotes or commas.
desk_name=$(xprop -root -notype | grep '^_NET_DESKTOP_NAMES = ' | cut -d ' ' -f 3- | tr -d '"' | sed 's/, /\n/g' | \
  nl -v0 -w1 -s: | grep "^${desk_num}:" | cut -d: -f2)

# If there is a matching window, then switch to it. The logic is based on
# observing what `wmctrl -xl` shows for dom0 and domU windows.
if [ "$desk_name" = "$DOM0" ]
then
  if wmctrl -xl | grep -v 'N/A N/A | grep --silent --fixed-strings "${target_class}"
  then
    wmctrl -xa "${target_class}"
    exit 0
  fi
else
  if wmctrl -xl | grep --silent --fixed-strings "${desk_name}:${target_class}"
  then
    wmctrl -xa "${desk_name}:${target_class}"
    exit 0
  fi
fi

# If there was not matching window, then start a new instace.
notify-send "Starting '$@' on $desk_name"
if [ "$desk_name" = "$DOM0" ]
then
  "$@" || notify-send "Failed to run '$@' on $desk_name"
else
  # It is not strictly necessary to start the domain manually (qvm-run would do
  # it if necessary), but this allows better error messages.
  qvm-start --skip-if-running -- "$desk_name" 2>&1 | ifne xargs -0 notify-send "Starting domain failed" || exit 1
  qvm-run -q "$desk_name" -- "$@" 2>&1 || notify-send "Failed to start '$@' on $desk_name"
fi

Installation

Save the scripts to a directory in your PATH in dom0 (non-root user) and make them executable.

Carefully review the contents of the scripts and make sure that you understand what they do. If you have any doubts, do not run them.

Usage

manage-workspaces

Before running this script for the first time, make a backup of your ~/Desktop directory. Running the script will hide the majority of your desktop icons (in order to only have the icons for the current workspace left visible). If you don’t want to keep using the script, you need to unhide your icons manually (restore the backup or see removal steps below).

The manage-workspaces script is meant to be running continously and takes care of moving windows between workspaces and hiding and showing icons based on the current workspace. You can run it manually to try it and see any potential error messages (it may be possible that some dependencies need to be installed). If it runs successfully, you can configure XFCE to start it at the beginning of the session.

You can add, remove and rename workspaces either before or after starting the script.

switch-to-or-run-in-workspace

The switch-to-or-run-in-workspace script is meant to be triggered by keyboard shortcuts (but can be run manually as well for testing). It takes two parameters, the windows class and the command name. You can get the window class of any application by launching it then finding the corresponding window in the output of the wmctrl -xl command.

Example shortcut configuration:

shortcut command description
alt-f2 switch-to-or-run-in-workspace xfce4-appfinder xfce4-appfinder --collapsed Application launcher
ctrl-alt-f switch-to-or-run-in-workspace org.mozilla.firefox firefox Firefox
ctrl-alt-t switch-to-or-run-in-workspace xfce4-terminal xfce4-terminal Terminal

How does it work under the hood?

  • wmctrl allows moving windows to specific workspaces.
  • xprop allows accessing window metadata on a lower level. This is easier to parse and allows us to listen for window and workspace events instead of polling them with wmctrl.
  • The qube for a window can be determined from the window metadata.
  • The qube for a launcher can be determined from the .desktop file.
  • By default, xfce does not show icons on the desktop for hidden files, therefore a desktop icon can be hidden by adding a dot to the start of the filename.

How to remove the scripts?

  • Remove manage-workspaces from the session autostart and kill the one that is already running (or log out and back in to start a new session without it).
  • When the script is not running, some desktop icons will be stuck in a hidden state that needs to be fixed.
    • If you made a backup of ~/Desktop directory, then restore it.
    • Otherise remove the dot from the start of the desktop files in ~/Desktop to make them show up again. You can use the following bash command for this: cd ~/Desktop; for name in .*.desktop; do mv "$name" "${name#.}"; done.
  • Remove the keyboard shortcuts you added and restore the default ones if needed.
  • Remove the scripts.
3 Likes

Thanks for this. How is this different than devilspie2?

I did not know about devilspie2 or any other solution for qube-specific workspaces so I rolled my own. In retrospect, building on devilspie2 may have been a better approach, at least for the windows. But I also have workspace-specific desktop icons and keyboard shortcuts, which devilspie2 does not seem to support.

(It also seems to me that for devilspie2 one needs to set up rules, whereas I directly use the workspace names as rules. I may be wrong though, and even if rules need to be set up, it’s probably a do-once-then-never-bother-again effort.)

1 Like
1 Like