Qube-specific workspaces

Original forum link
https://forum.qubes-os.org/t/28513
Original poster
Rex
Created at
2024-08-25 16:17:13
Posts count
4
Likes count
5
Tags
xfce

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.

Example workspace list:

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

The resulting user experience

Requirements

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?

How to remove the scripts?