StavePaths.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.data |
| 2 | ||
| 3 | import java.io.File | |
| 4 | ||
| 5 | /** | |
| 6 | * XDG-compliant directory layout for Earhorn. | |
| 7 | * | |
| 8 | * The XDG Base Directory Specification defines three categories of user files: | |
| 9 | * - **Config** (`$XDG_CONFIG_HOME`, fallback `~/.config`): user preferences, | |
| 10 | * settings — small files the user can edit. | |
| 11 | * - **Cache** (`$XDG_CACHE_HOME`, fallback `~/.cache`): regenerable data. The | |
| 12 | * user can `rm -rf` this at any time and lose nothing except speed. Album art | |
| 13 | * cache, lyrics cache. | |
| 14 | * - **Data** (`$XDG_DATA_HOME`, fallback `~/.local/share`): user-created | |
| 15 | * content that should persist across reinstalls. Playlists, session state. | |
| 16 | * | |
| 17 | * This object is the single source of truth for Earhorn's directory layout. Each | |
| 18 | * store previously had its own `defaultFooDir()` companion helper, which was | |
| 19 | * fine but spread the XDG logic across four files. | |
| 20 | * | |
| 21 | * All directories live under an `earhorn/` subdirectory of their XDG base, which | |
| 22 | * is created owner-only (0700) — see [ensurePrivateDir]. | |
| 23 | */ | |
| 24 | object StavePaths { | |
| 25 | ||
| 26 | /** `$XDG_CONFIG_HOME/earhorn/` or `~/.config/earhorn/`. User preferences. */ | |
| 27 | val configDir: File by lazy { xdgDir("XDG_CONFIG_HOME", ".config") } | |
| 28 | ||
| 29 | /** `$XDG_CACHE_HOME/earhorn/` or `~/.cache/earhorn/`. Disposable regenerable data. */ | |
| 30 | val cacheDir: File by lazy { xdgDir("XDG_CACHE_HOME", ".cache") } | |
| 31 | ||
| 32 | /** `$XDG_DATA_HOME/earhorn/` or `~/.local/share/earhorn/`. User content. */ | |
| 33 | val dataDir: File by lazy { xdgDir("XDG_DATA_HOME", ".local/share") } | |
| 34 | ||
| 35 | // ---- Specific subdirs --------------------------------------------------- | |
| 36 | ||
| 37 | /** Image cache for Coil — album art, thumbnails. */ | |
| 38 | val imageCacheDir: File get() = File(cacheDir, "images") | |
| 39 | ||
| 40 | /** Lyrics cache. [dev.stave.lyrics.LyricsRepository] uses this. */ | |
| 41 | val lyricsCacheDir: File get() = File(cacheDir, "lyrics") | |
| 42 | ||
| 43 | /** User-saved playlists. [dev.stave.playlist.M3uPlaylistStore] uses this. */ | |
| 44 | val playlistsDir: File get() = File(dataDir, "playlists") | |
| 45 | ||
| 46 | // ---- Helpers ------------------------------------------------------------ | |
| 47 | ||
| 48 | /** | |
| 49 | * Create [dir] (and parents) if needed and restrict it to the owner (0700). | |
| 50 | * Earhorn's directories hold listening history (session queue, lyrics cache and | |
| 51 | * exports) and stream URLs that may carry server tokens, so other local users | |
| 52 | * have no business reading them. Best effort: on a filesystem without POSIX | |
| 53 | * permissions the directory is still created and used. | |
| 54 | */ | |
| 55 | fun ensurePrivateDir(dir: File): File { | |
| 56 | dir.mkdirs() | |
| 57 | setPosix(dir, "rwx------") | |
| 58 | return dir | |
| 59 | } | |
| 60 | ||
| 61 | /** Restrict an existing file Earhorn wrote to the owner (0600). Best effort. */ | |
| 62 | fun restrictToOwner(file: File) { | |
| 63 | setPosix(file, "rw-------") | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Replace [target] in one step: write to a fresh, uniquely named temp file beside it | |
| 68 | * (created 0600 on POSIX), fsync, then rename over [target] — atomically where the | |
| 69 | * filesystem allows, else a plain replace. The live file is never written in place, | |
| 70 | * so a crash leaves the old or the new content, never a truncated file, and two | |
| 71 | * writers never share a temp file. The result is owner-only (0600). | |
| 72 | * | |
| 73 | * Callers that may run concurrently still need their own lock so the *latest* | |
| 74 | * snapshot is the one that lands last. | |
| 75 | */ | |
| 76 | fun writeAtomically(target: File, tempPrefix: String, write: (java.io.OutputStream) -> Unit) { | |
| 77 | val dir = target.absoluteFile.parentFile ?: File(".") | |
| 78 | ensurePrivateDir(dir) | |
| 79 | val tmp = java.nio.file.Files.createTempFile(dir.toPath(), tempPrefix, ".tmp") | |
| 80 | try { | |
| 81 | java.io.FileOutputStream(tmp.toFile()).use { out -> | |
| 82 | write(out) | |
| 83 | out.flush() | |
| 84 | out.fd.sync() | |
| 85 | } | |
| 86 | try { | |
| 87 | java.nio.file.Files.move( | |
| 88 | tmp, target.toPath(), | |
| 89 | java.nio.file.StandardCopyOption.REPLACE_EXISTING, | |
| 90 | java.nio.file.StandardCopyOption.ATOMIC_MOVE, | |
| 91 | ) | |
| 92 | } catch (_: java.nio.file.AtomicMoveNotSupportedException) { | |
| 93 | java.nio.file.Files.move(tmp, target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) | |
| 94 | } | |
| 95 | restrictToOwner(target) | |
| 96 | } finally { | |
| 97 | java.nio.file.Files.deleteIfExists(tmp) | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | /** Remove temp files a crashed [writeAtomically] left in [dir]. */ | |
| 102 | fun deleteStaleTemps(dir: File, tempPrefix: String) { | |
| 103 | dir.listFiles { f -> f.name.startsWith(tempPrefix) && f.name.endsWith(".tmp") } | |
| 104 | ?.forEach { it.delete() } | |
| 105 | } | |
| 106 | ||
| 107 | private fun setPosix(f: File, perms: String) { | |
| 108 | try { | |
| 109 | if (f.exists()) { | |
| 110 | java.nio.file.Files.setPosixFilePermissions( | |
| 111 | f.toPath(), | |
| 112 | java.nio.file.attribute.PosixFilePermissions.fromString(perms), | |
| 113 | ) | |
| 114 | } | |
| 115 | } catch (_: UnsupportedOperationException) { | |
| 116 | // Non-POSIX filesystem — nothing to tighten. | |
| 117 | } catch (_: java.io.IOException) { | |
| 118 | } catch (_: SecurityException) { | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | private fun xdgDir(envVar: String, homeRelative: String): File { | |
| 123 | val xdg = System.getenv(envVar)?.takeIf { it.isNotBlank() } | |
| 124 | val base = if (xdg != null) File(xdg) else File(System.getProperty("user.home"), homeRelative) | |
| 125 | // Only the earhorn/ leaf is ours to tighten; never touch the XDG base itself. | |
| 126 | return ensurePrivateDir(File(base, "earhorn")) | |
| 127 | } | |
| 128 | } |


