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