EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

StavePaths.kt

package dev.stave.data

import java.io.File

/**
 * XDG-compliant directory layout for Earhorn.
 *
 * The XDG Base Directory Specification defines three categories of user files:
 *   - **Config** (`$XDG_CONFIG_HOME`, fallback `~/.config`): user preferences,
 *     settings — small files the user can edit.
 *   - **Cache** (`$XDG_CACHE_HOME`, fallback `~/.cache`): regenerable data. The
 *     user can `rm -rf` this at any time and lose nothing except speed. Album art
 *     cache, lyrics cache.
 *   - **Data** (`$XDG_DATA_HOME`, fallback `~/.local/share`): user-created
 *     content that should persist across reinstalls. Playlists, session state.
 *
 * This object is the single source of truth for Earhorn's directory layout. Each
 * store previously had its own `defaultFooDir()` companion helper, which was
 * fine but spread the XDG logic across four files.
 *
 * All directories live under an `earhorn/` subdirectory of their XDG base, which
 * is created owner-only (0700) — see [ensurePrivateDir].
 */
object StavePaths {

    /** `$XDG_CONFIG_HOME/earhorn/` or `~/.config/earhorn/`. User preferences. */
    val configDir: File by lazy { xdgDir("XDG_CONFIG_HOME", ".config") }

    /** `$XDG_CACHE_HOME/earhorn/` or `~/.cache/earhorn/`. Disposable regenerable data. */
    val cacheDir: File by lazy { xdgDir("XDG_CACHE_HOME", ".cache") }

    /** `$XDG_DATA_HOME/earhorn/` or `~/.local/share/earhorn/`. User content. */
    val dataDir: File by lazy { xdgDir("XDG_DATA_HOME", ".local/share") }

    // ---- Specific subdirs ---------------------------------------------------

    /** Image cache for Coil — album art, thumbnails. */
    val imageCacheDir: File get() = File(cacheDir, "images")

    /** Lyrics cache. [dev.stave.lyrics.LyricsRepository] uses this. */
    val lyricsCacheDir: File get() = File(cacheDir, "lyrics")

    /** User-saved playlists. [dev.stave.playlist.M3uPlaylistStore] uses this. */
    val playlistsDir: File get() = File(dataDir, "playlists")

    // ---- Helpers ------------------------------------------------------------

    /**
     * Create [dir] (and parents) if needed and restrict it to the owner (0700).
     * Earhorn's directories hold listening history (session queue, lyrics cache and
     * exports) and stream URLs that may carry server tokens, so other local users
     * have no business reading them. Best effort: on a filesystem without POSIX
     * permissions the directory is still created and used.
     */
    fun ensurePrivateDir(dir: File): File {
        dir.mkdirs()
        setPosix(dir, "rwx------")
        return dir
    }

    /** Restrict an existing file Earhorn wrote to the owner (0600). Best effort. */
    fun restrictToOwner(file: File) {
        setPosix(file, "rw-------")
    }

    /**
     * Replace [target] in one step: write to a fresh, uniquely named temp file beside it
     * (created 0600 on POSIX), fsync, then rename over [target] — atomically where the
     * filesystem allows, else a plain replace. The live file is never written in place,
     * so a crash leaves the old or the new content, never a truncated file, and two
     * writers never share a temp file. The result is owner-only (0600).
     *
     * Callers that may run concurrently still need their own lock so the *latest*
     * snapshot is the one that lands last.
     */
    fun writeAtomically(target: File, tempPrefix: String, write: (java.io.OutputStream) -> Unit) {
        val dir = target.absoluteFile.parentFile ?: File(".")
        ensurePrivateDir(dir)
        val tmp = java.nio.file.Files.createTempFile(dir.toPath(), tempPrefix, ".tmp")
        try {
            java.io.FileOutputStream(tmp.toFile()).use { out ->
                write(out)
                out.flush()
                out.fd.sync()
            }
            try {
                java.nio.file.Files.move(
                    tmp, target.toPath(),
                    java.nio.file.StandardCopyOption.REPLACE_EXISTING,
                    java.nio.file.StandardCopyOption.ATOMIC_MOVE,
                )
            } catch (_: java.nio.file.AtomicMoveNotSupportedException) {
                java.nio.file.Files.move(tmp, target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING)
            }
            restrictToOwner(target)
        } finally {
            java.nio.file.Files.deleteIfExists(tmp)
        }
    }

    /** Remove temp files a crashed [writeAtomically] left in [dir]. */
    fun deleteStaleTemps(dir: File, tempPrefix: String) {
        dir.listFiles { f -> f.name.startsWith(tempPrefix) && f.name.endsWith(".tmp") }
            ?.forEach { it.delete() }
    }

    private fun setPosix(f: File, perms: String) {
        try {
            if (f.exists()) {
                java.nio.file.Files.setPosixFilePermissions(
                    f.toPath(),
                    java.nio.file.attribute.PosixFilePermissions.fromString(perms),
                )
            }
        } catch (_: UnsupportedOperationException) {
            // Non-POSIX filesystem — nothing to tighten.
        } catch (_: java.io.IOException) {
        } catch (_: SecurityException) {
        }
    }

    private fun xdgDir(envVar: String, homeRelative: String): File {
        val xdg = System.getenv(envVar)?.takeIf { it.isNotBlank() }
        val base = if (xdg != null) File(xdg) else File(System.getProperty("user.home"), homeRelative)
        // Only the earhorn/ leaf is ours to tighten; never touch the XDG base itself.
        return ensurePrivateDir(File(base, "earhorn"))
    }
}