Skip to content

44. Settings and Files

Every program remembers something: Files its folders, Mail its accounts, Audio its favourites. And every program follows settings the desktop writes: the theme and the language. This chapter covers where those files go, how a settings file is written so that a crash never destroys it, and how a program hears that a file changed.

Where files go: XdgDirectories and AppDirectories

Package config · XdgDirectories introduced in 10. Pad, AppDirectories in 20. Files · source: XdgDirectories, AppDirectories

What it is for. The directories of the XDG Base Directory specification, which every Linux desktop follows, and the places of amadeOS inside them:

Method Default Holds
configHome() ~/.config Settings
dataHome() ~/.local/share The user's data that is not a document: a calendar, the trash
cacheHome() ~/.cache What can be rebuilt: covers, thumbnails, CD titles
amadeosConfig() ~/.config/amadeos The desktop's settings: theme.conf, locale.conf
appConfig(name) ~/.config/amadeos/<name> One program's settings
appCache(name) ~/.cache/amadeos/<name> One program's cache

AppDirectories is the pair a program needs, config and cache, built from the configName of its AppInfo; configFile(name) is a file in the first.

When to choose it. Ask Guice for AppDirectories and put settings into configFile("mail.conf") and rebuildable data into cache(). Data the user would miss goes below dataHome(), which XdgDirectories gives; Calendar keeps its calendar in ~/.local/share/amadeos/calendar (chapter 21). Documents go where the user saves them, never into these directories.

How it works. The variables XDG_CONFIG_HOME, XDG_DATA_HOME and XDG_CACHE_HOME are used when they hold an absolute path; the specification says a relative value must be ignored:

amade-commons · config/XdgDirectories.java
    /**
     * The variable if it holds an absolute path; the specification says relative values must be ignored.
     */
    private Path base(String variable, String fallback) {
        String configured = environment.get(variable);
        if (configured != null && configured.startsWith("/")) return Path.of(configured);
        return Path.of(userHome).resolve(fallback);
    }

The constructor takes the environment and the home directory as parameters, and XdgDirectories.system() passes the real ones. A test passes a temporary directory, and nothing it does touches the developer's own settings.

Test: defaults, overrides and a relative value
amade-commons · test/config/ConfigTest.java
    @Test
    void xdgDefaultsAndOverrides() {
        XdgDirectories defaults = new XdgDirectories(Map.of(), "/home/ralf");
        assertEquals(Path.of("/home/ralf/.config/amadeos/mail"), defaults.appConfig("mail"));
        assertEquals(Path.of("/home/ralf/.cache/amadeos/mail"), defaults.appCache("mail"));
        assertEquals(Path.of("/home/ralf/.local/share"), defaults.dataHome());

        XdgDirectories custom = new XdgDirectories(Map.of("XDG_CONFIG_HOME", "/cfg", "XDG_CACHE_HOME", "relative"), "/home/ralf");
        assertEquals(Path.of("/cfg/amadeos"), custom.amadeosConfig());
        assertEquals(Path.of("/home/ralf/.cache"), custom.cacheHome(), "relative values are ignored");
    }

The pictures folder

Package config · XdgDirectories.pictures() introduced in 34. Screenshot, used by Fractals and Imagescan · source: XdgDirectories

What it is for. The folder where the user keeps pictures, under its name in the user's language: ~/Pictures, ~/Bilder, ~/Images. The desktop's first login writes the names of these folders into user-dirs.dirs in the configuration directory.

When to choose it. As the place a program saves pictures without asking, as Screenshot does, and as the folder a file chooser for pictures opens first, as in Fractals and Imagescan. The folder may not exist yet: create it before saving, or fall back to home() in a file chooser.

How it works. It reads the line XDG_PICTURES_DIR= and accepts the two forms the file may contain, "$HOME/Bilder" and an absolute path. Without the file or the line, the answer is ~/Pictures:

amade-commons · config/XdgDirectories.java
    /**
     * The user's pictures folder: {@code XDG_PICTURES_DIR} from {@code user-dirs.dirs} in the config home, which
     * holds the translated name ({@code ~/Bilder}, {@code ~/Images}), else {@code ~/Pictures}. The folder may not
     * exist yet; a program that saves into it creates it, a file chooser falls back to the home folder.
     */
    public Path pictures() {
        Path home = Path.of(userHome);
        try {
            Path dirs = configHome().resolve("user-dirs.dirs");
            if (Files.isRegularFile(dirs)) {
                for (String line : Files.readAllLines(dirs)) {
                    line = line.strip();
                    if (!line.startsWith("XDG_PICTURES_DIR=")) continue;
                    String value = line.substring(line.indexOf('=') + 1).replace("\"", "");
                    if (value.startsWith("$HOME")) return home.resolve(value.substring(5).replaceFirst("^/+", ""));
                    if (value.startsWith("/")) return Path.of(value);
                }
            }
        } catch (IOException | RuntimeException e) {
            // an unreadable file counts as no file
        }
        return home.resolve("Pictures");
    }
Test: no file, a translated name, an absolute path
amade-commons · test/config/ConfigTest.java
    @Test
    void picturesFolderFollowsTheUserDirectories() throws Exception {
        XdgDirectories xdg = new XdgDirectories(Map.of("XDG_CONFIG_HOME", dir.resolve("cfg").toString()), "/home/anna");
        assertEquals(Path.of("/home/anna/Pictures"), xdg.pictures(), "no user-dirs.dirs");

        Files.createDirectories(dir.resolve("cfg"));
        Files.writeString(dir.resolve("cfg/user-dirs.dirs"),
                "# written by xdg-user-dirs-update\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\n");
        assertEquals(Path.of("/home/anna/Bilder"), xdg.pictures());

        Files.writeString(dir.resolve("cfg/user-dirs.dirs"), "XDG_PICTURES_DIR=\"/data/photos\"\n");
        assertEquals(Path.of("/data/photos"), xdg.pictures());
    }

Settings files: PropertiesFile

Package config · introduced in 20. Files · source: PropertiesFile

What it is for. Reading and writing a .properties file in UTF-8, safely. Files, Calendar, Mail, WritePad, Fractals, Imagescan and Audio keep their settings this way.

When to choose it. For settings: a handful of keys and values that a user could read and fix in a text editor. A missing key is no error, so a newer version can add keys and an older one ignores them. For structured data, such as Audio's list of favourite stations, write JSON; for documents, a format of their own.

How it works. Writing goes to a temporary file in the same directory, which is made readable by its owner only, and is then renamed over the old file. A rename within one directory is atomic, so a crash leaves the old file or the new one, never half a file. Where the file system cannot rename atomically, an ordinary replace is the fallback:

amade-commons · config/PropertiesFile.java
    public static void store(Path file, Properties properties, String comment) {
        try {
            Files.createDirectories(file.getParent());
            Path temp = Files.createTempFile(file.getParent(), file.getFileName().toString(), ".tmp");
            try {
                restrictToOwner(temp);
                try (Writer writer = Files.newBufferedWriter(temp, StandardCharsets.UTF_8)) {
                    properties.store(writer, comment);
                }
                try {
                    Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
                } catch (AtomicMoveNotSupportedException e) {
                    Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
                }
            } finally {
                Files.deleteIfExists(temp);
            }
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot write " + file, e);
        }
    }

load returns empty properties for a file that does not exist, and throws UncheckedIOException for one that cannot be read. The usual shape around it is a repository with a record of defaults:

Properties p = PropertiesFile.load(directories.configFile("files.conf"));
boolean hidden = Boolean.parseBoolean(p.getProperty("view.showHidden", "false"));

Pitfall: settings that stop the program

A settings file can be broken: a full disk, an edit by hand. If reading it throws and nobody catches it, the program does not start. Catch the error where the settings are read, log it and go on with the defaults (chapter 20).

Owner only

Settings files are rw-------, because some hold things that are nobody else's business: Mail's accounts name servers and user names. Passwords never go into them; they go into the keyring.

Test: private and atomic, no temporary file left behind
amade-commons · test/config/ConfigTest.java
    @Test
    void propertiesFileIsPrivateAndAtomic() throws Exception {
        Path file = dir.resolve("sub/settings.conf");
        Properties p = new Properties();
        p.setProperty("key", "Grüße");
        PropertiesFile.store(file, p, "test");
        assertEquals("Grüße", PropertiesFile.load(file).getProperty("key"));
        if (Files.getFileAttributeView(file, PosixFileAttributeView.class) != null) {
            assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
        }
        try (var files = Files.list(file.getParent())) {
            assertEquals(1L, files.count(), "no temporary files left behind");
        }
        assertTrue(PropertiesFile.load(dir.resolve("missing.conf")).isEmpty());
    }

Following a file: ConfigWatcher

Package config · introduced in 10. Pad · source: ConfigWatcher

What it is for. Running an action when one of some files in a directory is created, changed or deleted. ThemeStore and LocaleStore use it to follow the desktop's settings; a program can use it for a file another program writes.

ConfigWatcher watcher = new ConfigWatcher(directory, Set.of("bookmarks.conf"), "bookmarks-watcher", this::reload);
watcher.start();
...
watcher.close();

When to choose it. For a file someone else writes while the program runs. A file only the program itself writes needs no watching.

How it works. It registers the directory, not the files, with Java's WatchService, because a file that is replaced by a rename is a new file and a watch on the old one would see nothing. A daemon thread waits for events, ignores those of other files, waits 150 ms for the writer to finish, then runs the action:

amade-commons · config/ConfigWatcher.java
    private void loop(WatchService service) {
        try {
            while (true) {
                WatchKey key = service.take();
                boolean relevant = false;
                for (WatchEvent<?> event : key.pollEvents()) {
                    Object context = event.context();
                    relevant |= event.kind() == StandardWatchEventKinds.OVERFLOW
                            || (context instanceof Path p && fileNames.contains(p.getFileName().toString()));
                }
                key.reset();
                if (relevant) {
                    Thread.sleep(SETTLE_MILLIS);
                    try {
                        onChange.run();
                    } catch (RuntimeException e) {
                        LOG.warn("Reacting to a change in {} failed", directory, e);
                    }
                }
            }
        } catch (InterruptedException | ClosedWatchServiceException e) {
            // stopped
        }
    }

An OVERFLOW event, which means events were lost, counts as a change, so the action reads again rather than missing one. start() returns false if the file system cannot watch the directory; the program then simply does not follow changes. close() stops the thread.

Pitfall: the action runs on the watching thread

The action is called on the watcher's own thread. Reading the file there is right; touching Swing is not. Switch to the EDT for that, as AmadeUI.follow does (chapter 42).

The desktop's files

These are the files amadeOS programs share. Programs read them; the desktop writes the first three.

File Written by Read by Holds
~/.config/amadeos/theme.conf Settings ThemeStore The theme id, e.g. dark-slate
~/.config/amadeos/icon_theme.conf Settings ThemeStore The icon theme, e.g. NsCDE
~/.config/amadeos/font.conf Settings ThemeStore The text size, 1 to 7, e.g. 4
~/.config/amadeos/locale.conf the desktop's language switcher LocaleStore The desktop language, e.g. de_DE.UTF-8
~/.config/amadeos/themes/<id>/theme.properties the user or a theme package ThemeRegistry A custom theme (chapter 42)
~/.config/amadeos/<app>/language.conf the program's language setting LocaleSettings desktop or a language code
~/.config/amadeos/<app>/*.conf the program the program Its own settings, through PropertiesFile