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

StavePaths.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.data
2
3import 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 */
24object 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}