Writing a simple task Applet for Cinnamon Desktop

Not too long ago I installed Arch Linux. The laws of the universe dictate that I shout it from the mountain tops, so here we are.

Not too long ago I installed Arch Linux. The laws of the universe dictate that I shout it from the mountain tops, so here we are. When I was deciding on which desktop environment to use, I wanted to try something new; I’ve used Ubuntu quite a bit so I wanted to steer away from Unity and Gnome. Enter Cinnamon. It’s reasonably popular and I’ve heard good things about it, so I thought I’d give it a try.

I’ve never developed for Gnome or Cinnamon before, so creating an Applet was new territory for me. It was an interesting journey, so I’ll share my experience here in the hopes of helping others looking to do the same.

A Basic Use Case

When I’m using my desktop PC at home, I frequently need to change the audio output between my speakers and my headphones. On Windows this is rather trivial; I can click the volume icon in the system tray, click the device drop-down, and select the output device I want.

To do this by keyboard shortcut I use SoundSwitch, which is also useful because I can tell it which devices I want to have in the rotation, removing clutter such as digital-only or virtual output devices. That means I can quickly switch between my speakers and my headphones from any application by using the keyboard shortcut, Ctrl+Alt+F11.

As I’ll explain in the next section, on Linux it’s not so simple. On the bright side, that means I get to create something! My initial goal was simple: Switch the audio output device by using a keyboard shortcut.

Exploring a Solution

Why did I need to create something custom in the first place? In the case of my sound card, the speakers and headphones show up as two sinks on a single device. The active sink is triggered by physically plugging and unplugging the headphones, and, in Gnome and Cinnamon at least, it’s not possible to force one or the other to be active.

Cinnamon’s sound settings don’t have an option to change the active sink.

More fine-grained control can be gained by using alsamixer. It provides a mixer interface for the devices and sinks, as well as some low-level sound settings. After selecting my sound card with F6, I’m presented with the mixer.

Headphones and Front are the sinks that I’m after. After flipping the Auto-Mute Mode setting to Disabled, I can switch the outputs by setting one slider to 0 and the other to 100. There’s only one problem here: alsamixer is an interactive terminal application, and thus not suitable for scripting.

Thankfully, amixer provides similar functionality but with non-interactive commands.

amixer -c0 set "Auto-Mute Mode" Disabled # Disable auto-muting
amixer -c0 set 'old-device-name' 0%      # Mute the old device
amixer -c0 set 'new-device-name' 100%    # Enable the new device

Additionally, I need to use pactl to tell the OS which sink we’re actively using.

pactl set-sink-port 0 'sink-name'        # Set the desired sink

So far, so good. If I run these with the proper device and sink names, I can switch the output to the device I want.

Scripting It Up

To get one step closer to my goal, I needed to have a script that would run the above commands in such a way that the output device would switch back and forth. I also opted to use notify-send to display a transient desktop notification, letting me know that the output was changed successfully. This is what I came up with:

#!/bin/bash
CURRENT_PROFILE=$(pactl list sinks | grep "Active Port" | cut -d ' ' -f 3-)
NOTIFICATION_DURATION_MS=2000

notify() {
	notify-send --hint=int:transient:1 -t $NOTIFICATION_DURATION_MS "Sound Switch" "$1"
}

amixer -c0 set "Auto-Mute Mode" Disabled

if [ "$CURRENT_PROFILE" = "analog-output-lineout" ]; then
	pactl set-sink-port 0 "analog-output-headphones"
	amixer -c0 set Front 0% > /dev/null
	amixer -c0 set Headphone 100% > /dev/null
	notify "Sound output switched to speakers"
else
	pactl set-sink-port 0 "analog-output-lineout"
	amixer -c0 set Headphone 0% > /dev/null
	amixer -c0 set Front 100% > /dev/null
	notify "Sound output switched to headphones"
fi

There we have it! Every time this script is run, it will switch between headphones and speakers.

Keyboard Shortcut

Now that I had a script for my solution, I needed to tell Cinnamon to run it when the desired keyboard shortcut was pressed. Thankfully this isn’t hard. The Keyboard menu has a tab for adding custom shortcuts. After setting the desired key combination, I pointed it to the script I wrote.

I wanted to make the shortcut Ctrl+Alt+F11 like SoundSwitch, but that’s reserved for TTY, so Ctrl+F11 did the trick.

That seemed to meet my requirements! Pressing Ctrl+F11 will call the script, which will switch the audio device and send a nice little notification.

Taking it Up a Notch

I was actually using the above script in my workflow for a while, but it felt like something was missing. I wanted to have an indicator of which device was currently selected, as well as the ability to switch devices with a mouse click. The system tray seemed to be the best fit for this. For a while I tried searching for tutorials on writing applications that sit in the system tray on Cinnamon, with surprisingly little results. Eventually I realized why: system tray applications on Cinnamon have a name: Applets.

Writing Our First Applet

As a user, I’ve been really happy with Cinnamon so far. It’s simple, beautiful, and customizable. As a developer, on the other hand, I’ve been extremely frustrated with it.

Cinnamon is a fork of Gnome. Because of this, it inherits a lot of functionality from it. This is a good thing: Gnome is very well documented (with some notable exceptions) and popular, which makes it relatively welcoming to newcomers like myself.

The problem is that Cinnamon’s diversions from Gnome are not well documented and were, at least for me, very hard to track down, or in some cases non-existent.

They do provide a quick tutorial on how to write a simple Applet. I used this as a jumping off point to start tinkering. Even more helpful was this blog post by Eli Billauer. With these resources I was able to make an Applet that, when clicked in the system tray, would run the same commands as our script above. The two necessary files are metadata.json and applet.js. Here is what my first applet.js looked like:

const Applet = imports.ui.applet;
const GLib = imports.gi.GLib;

function run(cmd) {
    try {
        let [result, stdout, stderr] = GLib.spawn_command_line_sync(cmd);
        if (stdout !== null) {
            return stdout.toString();
        }
    } catch (error) {
        global.logError(error.message);
    }
}

function getCurrentDevice() {
    const output = run('pactl list sinks');
    const lines = output.split('\n');
    for (let i = 0; i < lines.length; i += 1) {
        const line = lines[i];
        if (line.includes('Active Port')) {
            const columns = line.split(' ');
            return columns[2];
        }
    }

    throw new Error('Couldn\'t find current audio device');
}

function switchDevices() {
    const device = getCurrentDevice();

    // Defaults
    let targetDevice = 'analog-output-headphones';
    let deviceToDisable = 'Front';
    let deviceToEnable = 'Headphone';
    let deviceNotifyName = 'speakers';

    if (device === 'analog-output-headphones') {
        targetDevice = 'analog-output-lineout';
        deviceToDisable = 'Headphone';
        deviceToEnable = 'Front';
        deviceNotifyName = 'headphones';
    }

    GLib.spawn_command_line_async(`pactl set-sink-port 0 ${targetDevice}`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToDisable} 0%`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToEnable} 100%`);
    GLib.spawn_command_line_async(`notify-send --hint=int:transient:1 -t 2000 "Sound Switch" "Sound output switched to ${deviceNotifyName}"`);
}

class MyApplet extends Applet.IconApplet {
    constructor(orientation, panelHeight, instanceId) {
        super(orientation, panelHeight, instanceId);
        
        this.updateIcon = this.updateIcon.bind(this);
        this.updateIcon();
        this.set_applet_tooltip(_('Click to switch audio devices'));
    }

    updateIcon() {
        const currentDevice = getCurrentDevice();
        if (currentDevice === 'analog-output-lineout') {
            this.set_applet_icon_symbolic_name('audio-headphones');
        } else {
            this.set_applet_icon_symbolic_name('multimedia-volume-control');
        }
    }

    on_applet_clicked() {
        global.log('Switching audio devices');
        switchDevices();
        this.updateIcon();
    }
}

function main(metadata, orientation, panelHeight, instanceId) {
    return new MyApplet(orientation, panelHeight, instanceId);
}

When the Applet is clicked, the on_applet_clicked() function is called. From there I call the helper functions switchDevices() and updateIcon().

Where Cinnamon’s basic Applet tutorial started to break down for me was when I needed to capture the output of a system command, namely pactl, to get the current output device. Cinnamon’s util library doesn’t provide such a thing, so I had to look at the underlying implementation and use GLib commands directly.

Note: using GLib’s spawn_command_line_sync() is one of a few functions that will cause a warning to appear when you try to install your applet, claiming that it could cause issues with Cinnamon. I found this to be accurate when some commands I was running went longer than expected, causing the entire desktop to hang.

You can also see that I’m setting the icon based on the current output device. As mentioned in Cinnamon’s tutorial, the icon name references installed icons in /usr/share/icons. I found the icons audio-headphones and multimedia-volume-controlto be good analogs for headphones and speakers respectively. Note that I’m using symbolic icons because system tray icons should be simple and monochrome.

The Applet can be installed from the Applets menu, after which it will appear in the system tray.

All in all, the above code is not too different from Cinnamon’s example Applet. It’s an IconApplet that performs an action when clicked.

We’re done, right?

Applets and Signals

At this point I was pretty happy with my first Applet. It did a thing when clicked, it was snappy, it had pretty icons that updated properly. There were just two problems:

  1. The Applet itself isn’t triggered by a keyboard shortcut.
  2. If I leave the old script hooked up to the keyboard shortcut, the Applet icon will get out of sync.

Now, I won’t tell you how much time I spent trying to figure out how to get Applets to respond to global keyboard shortcuts. Suffice it to say I tried a lot of different ways and was extremely frustrated by the time I finally came up with a solution. The Eureka! moment happened when I was scouring the code of the built-in Sound Applet. I was looking there because the volume icon changes according to the current volume setting; it must have a connection to the system, keyboard or otherwise! I came across these lines of code:

this._sound_settings = new Gio.Settings({ schema_id: CINNAMON_DESKTOP_SOUNDS });
this._sound_settings.connect("changed::" + MAXIMUM_VOLUME_KEY, Lang.bind(this, this._on_sound_settings_change));

Let’s break this down. Arguably the most important part of these lines is the connect function call. Gnome and GTK use Signals to allow any object to trigger events on any other object, without the emitter needing to know anything about the receiver. I’ve done some work with Qt so this concept was familiar to me. You’ll notice in Gnome and Cinnamon documentation that many objects have a list of Signals just like they have a list of Properties and Functions. That’s what we’re going to take advantage of.

What is the Sound Applet connecting to? GSettings. Gio.Settings allows me to connect my Applet to the changed signal of a GSettings key. This is the connection I needed!

This was my new plan: Connect the Applet to a GSettings key of my own creation and update the device and icon when the setting changes. Let’s do it!

Creating A GSettings Schema

In order to compile my GSettings schema, I needed to create a .gschema.xml file in /usr/share/glib-2.0/schemas. Here’s what mine looks like:

<schemalist>
  <schema id="com.benjuan26.soundswitch" path="/com/benjuan26/soundswitch/">
    <key type="s" name="device">
      <default>"analog-output-lineout"</default>
      <summary>The current audio output device</summary>
      <description/>
    </key>
  </schema>
</schemalist>

The id and path properties of the schema should be unique. The convention for id is to use your reversed domain name followed by a unique identifier for your schema. The same content can be used for path, except using slashes as separators. Note that path must end in a trailing slash.

I’ve specified the setting called device of type string (hence the "s"). Once that was in place, I needed to tell GLib to re-compile the schemas:

glib-compile-schemas /usr/share/glib-2.0/schemas

Once that was done I could use the setting in my Applet.

Refactoring the Applet to Use GSettings

Now that I had set up GSettings, I could refactor my Applet. I connected it to a Gio.Settings object pointing to my newly created schema. When the Applet is clicked, instead of running the commands explicitly, it will simply flip the settings value and let the event flow pick it up. That will ensure that, no matter where the change is made, it will always be picked up by the Applet.

const Applet = imports.ui.applet;
const GLib = imports.gi.GLib;
const Gio = imports.gi.Gio;

const settingsSchemaId = 'com.benjuan26.soundswitch';
const settingsKey = 'device';
const notificationDuration = '2000';
const notificationTitle = 'Sound Switch';

function oppositeDevice(currentDevice) {
    if (currentDevice === 'analog-output-headphones') {
        return 'analog-output-lineout';
    }

    return 'analog-output-headphones';
}

function setDevice(device) {
    // Defaults
    let deviceToDisable = 'Front';
    let deviceToEnable = 'Headphone';
    let deviceNotifyName = 'speakers';

    if (device === 'analog-output-lineout') {
        deviceToDisable = 'Headphone';
        deviceToEnable = 'Front';
        deviceNotifyName = 'headphones';
    }

    GLib.spawn_command_line_async(`pactl set-sink-port 0 ${device}`);
    GLib.spawn_command_line_async(`amixer -c0 set "Auto-Mute Mode" Disabled`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToDisable} 0%`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToEnable} 100%`);
    GLib.spawn_command_line_async(`notify-send --hint=int:transient:1 -t ${notificationDuration} "${notificationTitle}" "Sound output switched to ${deviceNotifyName}"`);
}

class MyApplet extends Applet.IconApplet {
    constructor(orientation, panelHeight, instanceId) {
        super(orientation, panelHeight, instanceId);

        this._settings = new Gio.Settings({ schema_id: settingsSchemaId });
        const device = this._settings.get_string(settingsKey);

        this.updateIcon = this.updateIcon.bind(this);
        this.updateIcon(device);
        this.set_applet_tooltip(_('Click to switch audio devices'));

        this.settingsConnectId = this._settings.connect(`changed::${settingsKey}`, () => { this.onSettingsChanged(); });
    }

    updateIcon(device) {
        if (device === 'analog-output-lineout') {
            this.set_applet_icon_symbolic_name('audio-headphones');
        } else {
            this.set_applet_icon_symbolic_name('multimedia-volume-control');
        }
    }

    on_applet_clicked() {
        global.log('Sound switch clicked');
        const device = this._settings.get_string('device');
        this._settings.set_string(settingsKey, oppositeDevice(device));
    }

    onSettingsChanged() {
        const device = this._settings.get_string('device');
        global.log(`Updating sound output device to ${device} based on settings change`);

        setDevice(device);
        this.updateIcon(device);
    }
}

function main(metadata, orientation, panelHeight, instanceId) {
    return new MyApplet(orientation, panelHeight, instanceId);
}

Now the Applet does the same thing it did before, except now it does it by receiving a signal from our GSettings object. So, what’s left?

Activating the Applet by Keyboard Shortcut

All I had left to do was to use a keyboard shortcut to trigger the settings change. Since I already had Ctrl+F11 wired to a bash script, I simply modified the script to use the gsettings CLI to make the change.

#!/bin/bash
SCHEMA="com.benjuan26.soundswitch"
KEY=device

CURRENT_DEVICE=$(gsettings get ${SCHEMA} ${KEY})

if [ "$CURRENT_DEVICE" = "'analog-output-lineout'" ]; then
	echo "first one"
	gsettings set ${SCHEMA} ${KEY} "analog-output-headphones"
else
	echo "second one"
	gsettings set ${SCHEMA} ${KEY} "analog-output-lineout"
fi

That’s it! Now I can switch my audio output device by either clicking the icon in the system tray or using the key combination. In both cases an icon will be updated and a notification will be shown. Mission accomplished!

The complete code can be found on GitHub.