Home
Creating Apps in SailfishOS using Golang: Daemons and stuff
Liked 5 times

In this article we'll learn how to create parts of the code for SailfishOS apps in Golang. Namely we'll use an approach where the GUI is written in standard Qt and communicates with the daemon using D-Bus or a socket.

Writing a basic daemon that does nothing is fairly simple in Go:

func main() {
	gracefulShutdown := make(chan os.Signal, 1)
	signal.Notify(gracefulShutdown, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)

	logger := slog.Default()
	logger.Info("Starting daemon")

	sig := <-gracefulShutdown
	logger.Info("Shutdown signal received", slog.String("signal", sig.String()))
}

First, we create a channel that receives signals like SIGINT, SIGTERM and SIGQUIT, then we use signal.Notify() to send that signal to the channel. Next, we create a logger to write messages to the output. Finally, we wait for a signal on the channel and log a shutdown message.

A heartbeat daemon

Let's start with something simple and create a daemon that logs a message every 10 seconds:

	go func() {
		ticker := time.NewTicker(10 * time.Second)
		for range ticker.C {
			logger.Info("Heartbeat triggered")
		}
	}()

Put that between the "Starting daemon" message and the graceful shutdown wait. The log output confirms it works:

2026/10/16 20:06:25 INFO Starting daemon
2026/10/16 20:06:35 INFO Heartbeat triggered
2026/10/16 20:06:45 INFO Heartbeat triggered
2026/10/16 20:06:55 INFO Heartbeat triggered
2026/10/16 20:07:05 INFO Heartbeat triggered
2026/10/16 20:07:15 INFO Heartbeat triggered
2026/10/16 20:07:25 INFO Heartbeat triggered
2026/10/16 20:07:27 INFO Shutdown signal received signal=interrupt

Network changes daemon

Let's make it a little more specific to SailfishOS: we'll log a message whenever the system network changes. First, install godbus using go get:

go get github.com/godbus/dbus/v5

Next, let's create our listener! It will look like this:

func setupListener(ctx context.Context, logger *slog.Logger) error

We'll get the context and logger from the main function. This is generally a good approach, so we don't end up with 20 loggers, each configured slightly differently as the program evolves. Next, we'll define some constants for the D-Bus calls:

	const service = "net.connman"
	const iface = "net.connman.Manager"
	const object = "/"

Then we create the D-Bus connection:

	connection, err := dbus.ConnectSystemBus()
	if err != nil {
		return fmt.Errorf("failed connecting to system bus: %w", err)
	}
	defer connection.Close()

Don't forget to defer closing the connection. Once the function returns, we no longer need it, and otherwise we'd leave an unused connection open. Then we specify which events we're interested in:

	matcher := []dbus.MatchOption{
		dbus.WithMatchInterface(iface),
		dbus.WithMatchMember("PropertyChanged"),
		dbus.WithMatchObjectPath(object),
		dbus.WithMatchSender(service),
	}

	if err := connection.AddMatchSignal(matcher...); err != nil {
		return fmt.Errorf("failed to add matcher to d-bus: %w", err)
	}
	defer connection.RemoveMatchSignal(matcher...)

Strictly speaking, we don't need to remove the match signals here because the connection will close anyway. Still, it's a good habit: whenever you attach something, make sure to clean it up.

Then we'll add the channel we'll read signals from:

	signals := make(chan *dbus.Signal, 10)
	connection.Signal(signals)
	defer connection.RemoveSignal(signals)

This instructs the connection to pass signals to this channel. We're creating a buffered channel because we don't want to block for every message. At the same time, we don't want to buffer a thousand signals at once. Adjust this according to your app's needs.

Then we'll create the object and read its properties:

	manager := connection.Object(service, object)

	properties := make(map[string]dbus.Variant)
	if err := manager.Call(
		iface+".GetProperties", 0,
	).Store(&properties); err != nil {
		return fmt.Errorf("failed to get properties: %w", err)
	}

You should always read the relevant D-Bus documentation to find out which methods and properties are supported. ConnMan supports the GetProperties call which returns all its properties. The 0 is for flags, we don't need to specify any here. We store them in the map we created and return an error if the call fails.

Then we log the initial state for good measure:

	if state, ok := properties["State"].Value().(string); ok {
		logger.Info("initial network state", slog.String("state", state))
	}

Now let's start receiving signals!

	logger.Info("monitoring network activity...")

	for {
		select {
		case <-ctx.Done():
			return nil

		case sig := <-signals:
			if sig == nil || len(sig.Body) != 2 {
				continue
			}

			name, ok := sig.Body[0].(string)
			if !ok || name != "State" {
				continue
			}

			value, ok := sig.Body[1].(dbus.Variant)
			if !ok {
				continue
			}

			state, ok := value.Value().(string)
			if !ok {
				continue
			}

			logger.Info("network state changed", slog.String("state", state))
		}
	}

Here we create an infinite loop with a single select statement. It waits on multiple channels and runs the corresponding case as soon as one is ready. Otherwise, it just waits. The <-ctx.Done() case waits for the context we were given to be cancelled. When that happens, we return without an error and stop. This also runs all the deferred cleanup functions (removing matchers and closing the connection).

The other case waits for a signal and, when one arrives, proceeds with the business logic. You should always validate the signal. And if you used a shared connection (e.g. created in main() and passed to our function), you should always check that it's the signal you're interested in - if you add multiple matchers, you will get all signals that match them. Here, we know it comes from the interface we want because we created the connection and added our custom matchers. Because we watch for PropertyChanged, we'll get notified of every property change on the ConnMan object. That's why we have to filter out changes that don't interest us. Here, we check whether the property name is State and ignore it if it isn't. Then we get its value.

Wiring it up into main

We could create our own context, watch for the gracefulShutdown channel and mark the context as done when it happens, but there's a simpler way. We just need to rewrite it a bit. Instead of this:

	gracefulShutdown := make(chan os.Signal, 1)
	signal.Notify(gracefulShutdown, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)

We'll have this:

	ctx := context.Background()
	ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
	defer stop()

And instead of waiting for the signal, we'll wait for ctx.Done(). Inside our goroutine, we simply call setupListener():

		if err := setupListener(ctx, logger); err != nil {
			logger.Error("failed setting up listener", slog.String("error", err.Error()))
			os.Exit(1)
		}

The whole code looks like this:

func setupListener(ctx context.Context, logger *slog.Logger) error {
	const service = "net.connman"
	const iface = "net.connman.Manager"
	const object = "/"

	connection, err := dbus.ConnectSystemBus()
	if err != nil {
		return fmt.Errorf("failed connecting to system bus: %w", err)
	}
	defer connection.Close()

	matcher := []dbus.MatchOption{
		dbus.WithMatchInterface(iface),
		dbus.WithMatchMember("PropertyChanged"),
		dbus.WithMatchObjectPath(object),
		dbus.WithMatchSender(service),
	}

	if err := connection.AddMatchSignal(matcher...); err != nil {
		return fmt.Errorf("failed to add matcher to d-bus: %w", err)
	}
	defer connection.RemoveMatchSignal(matcher...)

	signals := make(chan *dbus.Signal, 10)
	connection.Signal(signals)
	defer connection.RemoveSignal(signals)

	manager := connection.Object(service, object)

	properties := make(map[string]dbus.Variant)
	if err := manager.Call(
		iface+".GetProperties", 0,
	).Store(&properties); err != nil {
		return fmt.Errorf("failed to get properties: %w", err)
	}

	if state, ok := properties["State"].Value().(string); ok {
		logger.Info("initial network state", slog.String("state", state))
	}

	logger.Info("monitoring network activity...")

	for {
		select {
		case <-ctx.Done():
			return nil

		case sig := <-signals:
			if sig == nil || len(sig.Body) != 2 {
				continue
			}

			name, ok := sig.Body[0].(string)
			if !ok || name != "State" {
				continue
			}

			value, ok := sig.Body[1].(dbus.Variant)
			if !ok {
				continue
			}

			state, ok := value.Value().(string)
			if !ok {
				continue
			}

			logger.Info("network state changed", slog.String("state", state))
		}
	}
}

func main() {
	ctx := context.Background()
	ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
	defer stop()

	logger := slog.Default()
	logger.Info("Starting daemon")

	go func() {
		if err := setupListener(ctx, logger); err != nil {
			logger.Error("failed setting up listener", slog.String("error", err.Error()))
			os.Exit(1)
		}
	}()

	<-ctx.Done()
	logger.Info("Shutdown signal received")
}

A note on using os.Exit() here: this is fine because the OS will reclaim the resources on exit, but if we had any custom cleanup logic, it would not be executed. You could call stop() and use, for example, a WaitGroup to wait for the goroutines to finish.

Now we'll build it for SailfishOS:

GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o our-cool-daemon .

Running it on my computer, I get the expected error because it doesn't use connman:

2026/10/16 20:53:54 INFO Starting daemon
2026/10/16 20:53:54 ERROR failed setting up listener error="failed to get properties: The name is not activatable"

But on my SailfishOS phone, I get:

2026/10/16 20:55:50 INFO Starting daemon
2026/10/16 20:55:50 INFO initial network state state=online
2026/10/16 20:55:50 INFO monitoring network activity...

After switching between Wi-Fi and mobile data a few times:

2026/10/16 20:58:21 INFO Starting daemon
2026/10/16 20:58:21 INFO initial network state state=online
2026/10/16 20:58:21 INFO monitoring network activity...
2026/10/16 20:58:27 INFO network state changed state=ready
2026/10/16 20:58:28 INFO network state changed state=idle
2026/10/16 20:58:28 INFO network state changed state=ready
2026/10/16 20:58:28 INFO network state changed state=online
2026/10/16 20:58:30 INFO network state changed state=ready
2026/10/16 20:58:30 INFO network state changed state=idle
2026/10/16 20:58:32 INFO network state changed state=ready
2026/10/16 20:58:32 INFO network state changed state=online
2026/10/16 20:58:38 INFO network state changed state=ready
2026/10/16 20:58:38 INFO network state changed state=online

Interprocess communication

Now the daemon can log information, but how do we communicate with it? You can either use D-Bus or a socket. D-Bus is more standard, but if you want your app accepted into the official Store, it's a no-go: Sailjail prevents your GUI app from making D-Bus calls unless they're whitelisted and you have permission to use them. So let's cover both!

First, we'll modify our .go file and store the state in a top-level variable. We'll also add a mutex to avoid race conditions. Add these outside any function, preferably at the top:

var currentState string
var currentStateMutex sync.RWMutex

And right before logging the current state in our listener, add this:

			currentStateMutex.Lock()
			currentState = state
			currentStateMutex.Unlock()

The mutex ensures that no other goroutine can read or write the state while we're updating it. Add that same block into our initial reading of the state before we enter the select loop. Because we're in a loop and don't return after each iteration, we can't use defer here. If there were multiple branches, you'd have to unlock the mutex in each one. Or you could wrap it in a func and defer there, e.g.:

			func() {
				currentStateMutex.Lock()
				defer currentStateMutex.Unlock()

				// some more complex logic with early returns etc.

				currentState = state
			}()

D-Bus

If we want others to call our daemon over D-Bus, we need to export something on it. Let's create this simple struct:

type DBusController struct {
}

func (receiver *DBusController) GetCurrentStatus() (string, *dbus.Error) {
	currentStateMutex.RLock()
	defer currentStateMutex.RUnlock()

	return currentState, nil
}

We use the mutex again, this time with RLock() instead of Lock(). This allows multiple readers at once, while Lock() also blocks all readers.

In main(), we register the object as a D-Bus handler in another goroutine:

	sessionBus, err := dbus.ConnectSessionBus()
	if err != nil {
		logger.Error("failed connecting to session bus", slog.String("error", err.Error()))
		os.Exit(1)
	}
	defer sessionBus.Close()

	go func() {
		controller := &DBusController{}
		if err := sessionBus.Export(controller, "/dev/chrastecky/MyCoolDaemon", "dev.chrastecky.MyCoolDaemon.Controller"); err != nil {
			logger.Error("failed exporting MyCoolDaemon", slog.String("error", err.Error()))
			return
		}
		reply, err := sessionBus.RequestName("dev.chrastecky.MyCoolDaemon", dbus.NameFlagDoNotQueue)
		if err != nil {
			logger.Error("failed to request dbus name", slog.String("error", err.Error()))
			return
		}
		if reply != dbus.RequestNameReplyPrimaryOwner {
			logger.Error("failed to request dbus name", slog.String("reply", reply.String()))
			return
		}
	}()

First, we connect to the session D-Bus, then create our small controller in a goroutine. Next, we export it under the path /dev/chrastecky/MyCoolDaemon and interface dev.chrastecky.MyCoolDaemon.Controller - these names are arbitrary, but it's good practice to use a unique prefix, such as your reversed domain name. Then you request the D-Bus to assign you the name dev.chrastecky.MyCoolDaemon - again, this can be anything. If you care about the app being eligible to the Jolla Store, you can only request the same name as your app's OrganizationName and ApplicationName under the Sailjail section in your .desktop file (though this is more for GUI apps, not for daemons which are not allowed in the Store anyway). The NameFlagDoNotQueue flag means you don't want to wait in a queue to own that name: you either register it immediately or fail.

Then we check whether the reply is RequestNameReplyPrimaryOwner which means that we're the primary owner of that name.

The sessionBus.Export() function analyzes the struct and registers every method whose last return type is *dbus.Error as a method on the session bus.

Once we build and run our daemon on the phone, we can test it with this command:

busctl --user call dev.chrastecky.MyCoolDaemon /dev/chrastecky/MyCoolDaemon dev.chrastecky.MyCoolDaemon.Controller GetCurrentStatus

The --user flag selects the session D-Bus rather than the system bus. The remaining arguments are, in order: service name, object path, interface, and method.

The command prints:

s "online"

This means the response is a string (s) with the value online.

The full code so far:

package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"sync"
	"syscall"

	"github.com/godbus/dbus/v5"
)

var currentState string
var currentStateMutex sync.RWMutex

func setupListener(ctx context.Context, logger *slog.Logger) error {
	const service = "net.connman"
	const iface = "net.connman.Manager"
	const object = "/"

	connection, err := dbus.ConnectSystemBus()
	if err != nil {
		return fmt.Errorf("failed connecting to system bus: %w", err)
	}
	defer connection.Close()

	matcher := []dbus.MatchOption{
		dbus.WithMatchInterface(iface),
		dbus.WithMatchMember("PropertyChanged"),
		dbus.WithMatchObjectPath(object),
		dbus.WithMatchSender(service),
	}

	if err := connection.AddMatchSignal(matcher...); err != nil {
		return fmt.Errorf("failed to add matcher to d-bus: %w", err)
	}
	defer connection.RemoveMatchSignal(matcher...)

	signals := make(chan *dbus.Signal, 10)
	connection.Signal(signals)
	defer connection.RemoveSignal(signals)

	manager := connection.Object(service, object)

	properties := make(map[string]dbus.Variant)
	if err := manager.Call(
		iface+".GetProperties", 0,
	).Store(&properties); err != nil {
		return fmt.Errorf("failed to get properties: %w", err)
	}

	if state, ok := properties["State"].Value().(string); ok {
		currentStateMutex.Lock()
		currentState = state
		currentStateMutex.Unlock()
		logger.Info("initial network state", slog.String("state", state))
	}

	logger.Info("monitoring network activity...")

	for {
		select {
		case <-ctx.Done():
			return nil

		case sig := <-signals:
			if sig == nil || len(sig.Body) != 2 {
				continue
			}

			name, ok := sig.Body[0].(string)
			if !ok || name != "State" {
				continue
			}

			value, ok := sig.Body[1].(dbus.Variant)
			if !ok {
				continue
			}

			state, ok := value.Value().(string)
			if !ok {
				continue
			}

			currentStateMutex.Lock()
			currentState = state
			currentStateMutex.Unlock()
			logger.Info("network state changed", slog.String("state", state))
		}
	}
}

func main() {
	ctx := context.Background()
	ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
	defer stop()

	logger := slog.Default()
	logger.Info("Starting daemon")

	go func() {
		if err := setupListener(ctx, logger); err != nil {
			logger.Error("failed setting up listener", slog.String("error", err.Error()))
			os.Exit(1)
		}
	}()

	sessionBus, err := dbus.ConnectSessionBus()
	if err != nil {
		logger.Error("failed connecting to session bus", slog.String("error", err.Error()))
		os.Exit(1)
	}
	defer sessionBus.Close()

	go func() {
		controller := &DBusController{}
		if err := sessionBus.Export(controller, "/dev/chrastecky/MyCoolDaemon", "dev.chrastecky.MyCoolDaemon.Controller"); err != nil {
			logger.Error("failed exporting MyCoolDaemon", slog.String("error", err.Error()))
			return
		}
		reply, err := sessionBus.RequestName("dev.chrastecky.MyCoolDaemon", dbus.NameFlagDoNotQueue)
		if err != nil {
			logger.Error("failed to request dbus name", slog.String("error", err.Error()))
			return
		}
		if reply != dbus.RequestNameReplyPrimaryOwner {
			logger.Error("failed to request dbus name", slog.String("reply", reply.String()))
			return
		}
	}()

	<-ctx.Done()
	logger.Info("Shutdown signal received")
}

type DBusController struct {
}

func (receiver *DBusController) GetCurrentStatus() (string, *dbus.Error) {
	currentStateMutex.RLock()
	defer currentStateMutex.RUnlock()

	return currentState, nil
}

Sockets

The big upside of D-Bus is that it's a standard and thus you get a lot for free and don't need to set up much - you create a struct with methods, register it and you're done. Sockets are different: a socket is simply a communication channel, and all the details of that communication are up to you. That makes them more powerful than D-Bus for some unusual use cases, but also means more setup.

First, we need to decide where to put the socket. If the daemon doesn't run as root, you can select some path your sailjailed gui has access to, e.g. /home/defaultuser/.config/dev.chrastecky/MyCoolApp/daemon.sock. You could do that as root too, but that introduces issues, such as not knowing the actual username. On SailfishOS, it has already changed once, from nemo to defaultuser, so relying on hardcoded paths isn't ideal. But assuming it's running non-root, you can write the following helpers:

func getSocketPath() (string, error) {
	const (
		guiAppCompany = "dev.chrastecky"
		guiAppName    = "MyCoolApp"
	)

	cfgDir, err := os.UserConfigDir()
	if err != nil {
		cfgDir, err = os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("failed determining both the config dir and home dir: %w", err)
		}
		cfgDir = filepath.Join(cfgDir, ".config")
	}

	socketPath := filepath.Join(cfgDir, guiAppCompany, guiAppName, "daemon.sock")
	return socketPath, nil
}

This determines where the socket will be stored, preferably using your config directory, with a fallback to your home directory.

func prepareSocketFile(socketPath string) error {
	if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
		return fmt.Errorf("failed creating socket directory: %w", err)
	}

	info, err := os.Stat(socketPath)
	if err == nil {
		if info.Mode()&os.ModeSocket == 0 {
			return fmt.Errorf("socket path %q exists and is not a socket", socketPath)
		}

		conn, dialErr := net.DialTimeout("unix", socketPath, 100*time.Millisecond)
		if dialErr == nil {
			_ = conn.Close()
			return fmt.Errorf("socket path %q is already in use", socketPath)
		}

		if err := os.Remove(socketPath); err != nil {
			return fmt.Errorf("failed removing stale socket file: %w", err)
		}

		return nil
	}

	if os.IsNotExist(err) {
		return nil
	}

	return fmt.Errorf("failed checking socket file: %w", err)
}

Some basic housekeeping for the socket file: first, we make sure the parent directory exists and return an error if it can't be created. Next, we check whether the socket already exists. If it doesn't, we simply return no error and continue. If it does, we need to find out how that happened. If it's not a socket, we simply return an error. If it is a socket that can be connected to (meaning something already listens there, most likely another instance of our daemon) we return an error. If it's just a stale socket left behind by an unclean exit, we try to remove it.

func listenSocket(socketPath string) (net.Listener, error) {
	if err := prepareSocketFile(socketPath); err != nil {
		return nil, err
	}

	listener, err := net.Listen("unix", socketPath)
	if err != nil {
		return nil, fmt.Errorf("failed listening on socket file %q: %w", socketPath, err)
	}

	return listener, nil
}

This finally creates the socket file and starts listening on it. Now let's wire it all up. Because the logic will be a little more complex, I'm moving the registration into a separate struct, the SocketController:

type SocketController struct {
	Listener net.Listener
	Logger   *slog.Logger
}

func (receiver *SocketController) Initialize() error {
	panic("todo")
}

The initialize function will set up accepting incoming clients in a goroutine. First add a wait group to the controller:

type SocketController struct {
	Listener net.Listener
	Logger   *slog.Logger
	wg       sync.WaitGroup
}

The wait group tracks every goroutine started through it and can wait for all of them to finish, which we'll make use of later. And then start a goroutine using the wait group:

func (receiver *SocketController) Initialize() error {
	receiver.wg.Go(receiver.acceptListeners)
	return nil
}

The accept listeners function looks like this:

func (receiver *SocketController) acceptListeners() {
	for {
		conn, err := receiver.Listener.Accept()
		if err != nil {
			if errors.Is(err, net.ErrClosed) {
				return
			}
			receiver.Logger.Error("Failed accepting socket connection", slog.String("error", err.Error()))
			return
		}

		receiver.wg.Go(func() {
			receiver.handleClient(conn)
		})
	}
}

We start an infinite loop that only stops when Accept() returns an error. This can happen, for example, when the socket is closed. We handle that case by returning without logging an error. Any other error gets logged.

Then we start another goroutine (through the wait group), passing it the client connection. We can use that connection both to read commands from the client and to send responses.

That means we create a goroutine for every client connecting to the socket.

First we'll defer closing the client when the function returns and create a scanner:

func (receiver *SocketController) handleClient(client net.Conn) {
	defer client.Close()

	scanner := bufio.NewScanner(client)
	for scanner.Scan() {
		// todo
	}

	if err := scanner.Err(); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.ErrClosedPipe) {
		receiver.Logger.Warn("Failed reading socket command", slog.String("error", err.Error()))
	}
}

Now for some bootstrapping. We'll create a scanner that reads what the client sends us. The loop runs each time a new line arrives from the client.

Afterwards, we check whether the scanner exited cleanly (e.g. the client disconnected) or something went wrong, and log any error.

Now for the loop, we'll need a helper struct which will represent a command:

type SocketCommand struct {
	RequestID string   `json:"request_id"`
	Name      string   `json:"name"`
	Args      []string `json:"args,omitempty"`
}

The request ID is a useful convention: the client could track requests itself, but returning a response with the same request ID is much simpler. Name is the command name, and Args contains any arguments we might want to pass to our function. We don't need any for our case, this is just for demonstration.

Now inside the scanner loop we unmarshal the command into the struct:

		var command SocketCommand
		if err := json.Unmarshal(scanner.Bytes(), &command); err != nil {
			receiver.Logger.Error("Failed parsing command", slog.String("error", err.Error()))
			return
		}

Note that returning on this error closes the client connection simply because it sent an invalid command. It might be better to send a response saying so, but that's left as an exercise to the reader.

To send the response, create a response struct.

type SocketResponse struct {
	RequestID string `json:"request_id"`
	Result    any    `json:"result"`
}

And then implement the loop:

		switch command.Name {
		case "GetCurrentStatus":
			response := &SocketResponse{
				RequestID: command.RequestID,
				Result:    receiver.GetCurrentStatus(),
			}
			responseBytes, _ := json.Marshal(response)
			if _, err := client.Write(append(responseBytes, []byte("\n")...)); err != nil {
				receiver.Logger.Error("Failed sending response", slog.String("error", err.Error()))
			}
		default:
			receiver.Logger.Error("Unknown command", slog.String("command", command.Name))
			return
		}

You could do all sorts of clever things here, such as maintaining a registry of command handlers or using reflection to find a method with the same name on the current controller.

Now we need to add a way to close our socket controller. But first, we need to track all client connections. So let's add some new properties to our controller:

type SocketController struct {
	Listener net.Listener
	Logger   *slog.Logger
	wg       sync.WaitGroup

	clientsMutex sync.Mutex
	clients      map[net.Conn]struct{}
}

Then, right at the very top of the handleClient() function add:

	receiver.clientsMutex.Lock()
	receiver.clients[client] = struct{}{}
	receiver.clientsMutex.Unlock()

	defer func() {
		receiver.clientsMutex.Lock()
		delete(receiver.clients, client)
		receiver.clientsMutex.Unlock()
	}()
	defer client.Close()

And now we finally close all connections:

func (receiver *SocketController) Close() error {
	receiver.clientsMutex.Lock()

	closeErrs := make([]error, 0, len(receiver.clients))
	for client := range receiver.clients {
		delete(receiver.clients, client)
		if err := client.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
			closeErrs = append(closeErrs, err)
		}
	}

	if err := receiver.Listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
		closeErrs = append(closeErrs, err)
	}

	receiver.clientsMutex.Unlock()
	receiver.wg.Wait()

	return errors.Join(closeErrs...)
}

Note that we're passing the listener from outside, so we really shouldn't close it here. As a general rule, whoever creates a resource should also free it. If the code is only used inside your own app it might be acceptable (and should be documented), otherwise it should be handled correctly. Handling that properly would add too much boilerplate to this already long post, so I'm taking the quick-and-dirty approach here.

We also need to initialize the clients map and since we already have a method for initializing, let's do that here. Otherwise you'd do that in a constructor function:

func (receiver *SocketController) Initialize() error {
	receiver.clientsMutex.Lock()
	receiver.clients = make(map[net.Conn]struct{})
	receiver.clientsMutex.Unlock()

	receiver.wg.Go(receiver.acceptListeners)
	return nil
}

Now, back in our main.go file, we wire everything together:

	go func() {
		socketPath, err := getSocketPath()
		if err != nil {
			logger.Error("failed getting socket path", slog.String("error", err.Error()))
			os.Exit(1)
		}
		listener, err := listenSocket(socketPath)
		if err != nil {
			logger.Error("failed listening socket", slog.String("error", err.Error()))
			os.Exit(1)
		}
		defer listener.Close() // will be closed by controller, but just to be sure

		controller := &SocketController{
			Listener: listener,
			Logger:   logger,
		}
		defer controller.Close()

		if err := controller.Initialize(); err != nil {
			logger.Error("failed registering socket", slog.String("error", err.Error()))
			os.Exit(1)
		}

		<-ctx.Done()
	}()

First, we initialize everything, then wait for the shutdown signal (<-ctx.Done()).

One note about the goroutines and synchronization above: there are many subtle bugs that won't bite you 99% of the time, but that remaining 1% will leave you scratching your head. The point of this article isn't to teach thread-safe programming, but to show one way to approach building SailfishOS daemons. Correct thread safety in Go is better served by other articles.

Packaging

Now that we have our daemon ready, we need to make an installable package out of it. Sadly, I haven't yet managed to build a Go binary inside the Sailfish SDK. My usual approach is to build it on the host and only do the packaging in sfdk. Luckily, Go without CGO can cross-compile easily. For example:

GOOS=linux GOARCH=arm64 go build -buildvcs=false -mod=readonly -trimpath -o our-cool-daemon .

Or for 32 bit:

GOOS=linux GOARCH=arm GOARM=7 go build -buildvcs=false -mod=readonly -trimpath -o our-cool-daemon .

A minimal RPM spec could look like this:

Name:           our-cool-daemon
Version:        1.0.0
Release:        1%{?dist}
Summary:        This daemon is really cool

License:        MIT
URL:            https://example.com
Source0:        %{name}-%{version}.tar.gz

%prep
%autosetup

%build

%install
install -Dpm 0755 %{name} %{buildroot}%{_bindir}/%{name}

%files
%{_bindir}/%{name}

Packaging a systemd unit

If you want the daemon to be started automatically, you'll need to create a systemd unit:

[Unit]
Description=Our Cool Daemon
After=user-session.target
After=dbus.socket
Requires=dbus.socket

[Service]
ExecStart=/usr/bin/our-cool-daemon
Restart=on-failure
RestartSec=5

[Install]
WantedBy=post-user-session.target

The two D-Bus lines can be removed if you don't use D-Bus. The packaging script then needs some new sections:

Name:           our-cool-daemon
Version:        1.0.0
Release:        1%{?dist}
Summary:        This daemon is really cool

License:        MIT
URL:            https://example.com
Source0:        %{name}-%{version}.tar.gz

%prep
%autosetup

%build

%install
install -Dpm 0755 %{name} %{buildroot}%{_bindir}/%{name}
install -Dpm 0644 %{name}.service %{buildroot}%{_userunitdir}/%{name}.service

%post
systemctl-user daemon-reload || :
if [ "$1" -eq 1 ]; then
  systemctl-user enable --now %{name}.service || :
else
  systemctl-user try-restart %{name}.service || :
fi

%preun
if [ "$1" -eq 0 ]; then
  systemctl-user disable --now %{name}.service || :
fi

%postun
systemctl-user daemon-reload || :

%files
%{_bindir}/%{name}
%{_userunitdir}/%{name}.service

Besides the service file itself, this adds script sections that run during installation and uninstallation. With this setup, the daemon is enabled and started on the first installation, then restarted on every update. The systemd user manager also reloads its configuration on every operation, ensuring it sees the new unit definition rather than the previous one (or nothing, on the first installation).

Now we're ready to move on to the GUI app!

The GUI

We'll build a very simple, separate GUI app with just a status label and a pull-down refresh action. Here's the basic skeleton:

import QtQuick 2.0
import Sailfish.Silica 1.0

Page {
    property string currentStatus: "unknown"
    id: page

    SilicaFlickable {
        id: flickable
        anchors.fill: parent
        contentHeight: column.height

        PullDownMenu {
            MenuItem {
                text: "Refresh"
                onClicked: {
                    // todo
                }
            }
        }

        Column {
            id: column
            spacing: Theme.paddingLarge
            width: page.width

            PageHeader {
                title: "Current network status"
            }

            Label {
                x: Theme.horizontalPageMargin
                horizontalAlignment: Text.AlignHCenter
                wrapMode: Label.WordWrap
                width: parent.width - Theme.horizontalPageMargin * 2
                text: qsTr("Current status: %1").arg(currentStatus)
            }
        }
    }
}

If we're using D-Bus, we don't even need C++ because SailfishOS exposes the Nemo.DBus module directly to QML. For a socket, we can't avoid writing some C++ (or Rust, or whatever you fancy).

So let's add D-Bus! First, import Nemo.DBus 2.0 at the very top alongside the other imports, then add this D-Bus object:

    DBusInterface {
        id: daemon

        bus: DBus.SessionBus
        service: "dev.chrastecky.MyCoolDaemon"
        path: "/dev/chrastecky/MyCoolDaemon"
        iface: "dev.chrastecky.MyCoolDaemon.Controller"

        function refresh() {
            typedCall("GetCurrentStatus", [], function(result) {
                page.currentStatus = result
            }, function(error, message) {
                console.warn("D-Bus error:", error, message)
            })
        }
    }

Don't forget to initialize the UI value too:

Component.onCompleted: daemon.refresh()

And, of course, wire the same function to the pull-down menu item.

For sockets, you have to write a custom C++ handler:

class DaemonClient : public QObject {
    Q_OBJECT

public:
    explicit DaemonClient(QObject *parent = nullptr)
        : QObject(parent) {}

    Q_INVOKABLE QString getCurrentStatus() {
        QLocalSocket socket;
        const QString socketPath = QDir(
            QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)
        ).filePath("daemon.sock");

        socket.connectToServer(socketPath);

        if (!socket.waitForConnected(1000)) {
            return "error: " + socket.errorString();
        }

        QJsonObject request{
            {"request_id", "1"},
            {"name", "GetCurrentStatus"}
        };

        QByteArray data = QJsonDocument(request).toJson(QJsonDocument::Compact);
        data.append('\n');

        socket.write(data);

        if (!socket.waitForBytesWritten(1000)) {
            return "error: " + socket.errorString();
        }

        QByteArray response;

        while (!response.contains('\n')) {
            if (!socket.waitForReadyRead(1000)) {
                return "error: " + socket.errorString();
            }

            response.append(socket.readAll());
        }

        const QJsonDocument document = QJsonDocument::fromJson(response.left(response.indexOf('\n')));

        if (!document.isObject()) {
            return "error: invalid JSON response";
        }

        return document.object().value("result").toString();
    }
};

Don't forget to register it in your main function:

qmlRegisterType<DaemonClient>("MyCoolDaemon", 1, 0, "DaemonClient");

Then in QML import MyCoolDaemon 1.0 and use this:

DaemonClient {
    id: daemon
}

Then use page.currentStatus = daemon.getCurrentStatus() wherever you need it.

Sailjail and D-Bus

As already mentioned, you can't call arbitrary D-Bus calls from your app if you want it in the official store. But the official Store doesn't allow dependencies on custom daemons anyway.

If your goal is simply to use Sailjail to show that your app won't do anything beyond its requested permissions, you can easily define your own Sailjail permission. Permissions live in /etc/sailjail/permissions on the device and use the .permission extension. The filename becomes the internal permission name that you'll declare in your desktop file. So let's create MyCoolDaemon.permission:

# x-sailjail-description = Access My Cool Daemon
# x-sailjail-long-description = Allow retrieving the current network status from My Cool Daemon.

dbus-user.talk dev.chrastecky.MyCoolDaemon
dbus-user.call dev.chrastecky.MyCoolDaemon=dev.chrastecky.MyCoolDaemon.Controller.GetCurrentStatus@/dev/chrastecky/MyCoolDaemon

Then, in your desktop file, add MyCoolDaemon to the permissions list. That way, you're fully sandboxed without being limited to Store-approved permissions:

[X-Sailjail]
# Simply add MyCoolDaemon to the list of permissions
Permissions=MyCoolDaemon;Internet
OrganizationName=dev.chrastecky
ApplicationName=MyCoolApp

Don't forget to bundle the .permission file with your app by adding it to the .spec file.


That concludes our tutorial on writing Go daemons for SailfishOS. Next time, we'll look at writing the business logic in Go, building it as a shared .so object, and calling the Go code directly from C++!


Tip: To comment on or like this post, open it on your favourite Fediverse instance, such as Mastodon or Lemmy.
© 2024 Dominik Chrástecký. All rights reserved.