StavePaths.kt
128 lines · 5.6 KB
Changed in: 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 | } |


