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")) } }