rahti_native/paths.rs
1//! Where an installed application's files are.
2//!
3//! A Rahti web project resolves everything against the working directory: the
4//! public assets are `public/`, the SQLite file is whatever relative path
5//! `DATABASE_URL` names, and a spilled upload goes to the system temporary
6//! directory. All three are correct for a server started from its own checkout
7//! and all three are wrong for an installed program, whose working directory
8//! is wherever the user happened to launch it from and whose installation
9//! directory is often not writable at all.
10//!
11//! So a packaged application resolves its paths from the operating system's
12//! own application directories, once, before the router is built.
13//!
14//! ## The rule
15//!
16//! **Nothing is written to the installation directory.** Resources shipped
17//! inside the package are read-only — on Android they are not even files, they
18//! are entries in an APK — and a Windows installation under `Program Files` is
19//! not writable by the user running it. Everything the application writes goes
20//! under [`AppPaths::data`] or [`AppPaths::cache`].
21//!
22//! ## Where they land
23//!
24//! | | Windows | Android |
25//! | --- | --- | --- |
26//! | [`data`](AppPaths::data) | `%LOCALAPPDATA%\<identifier>` | internal files directory |
27//! | [`config`](AppPaths::config) | `%APPDATA%\<identifier>` | `<files>/config` |
28//! | [`cache`](AppPaths::cache) | `%LOCALAPPDATA%\<identifier>\cache` | cache directory |
29//!
30//! Android's two directories are handed in by the host rather than guessed:
31//! only the Java side knows them, and Tauri asks it. [`AppPaths::from_host`]
32//! is that constructor; [`AppPaths::resolve`] is the one that reads the
33//! environment and is what a Windows package uses.
34//!
35//! Data against cache is the distinction the operating system acts on: Android
36//! deletes a cache directory when the device is short of space, and Windows
37//! roams `%APPDATA%` between machines on a domain while leaving
38//! `%LOCALAPPDATA%` where it is. So the database, uploads and logs are data;
39//! spilled upload parts are cache, because a spilled part exists for the
40//! duration of one request and losing it costs nothing.
41
42use std::path::{Path, PathBuf};
43
44use crate::error::NativeError;
45use crate::platform::Platform;
46
47const PUBLIC_DIR_ENV: &str = "RAHTI_PUBLIC_DIR";
48const SPILL_DIR_ENV: &str = "RAHTI_SPILL_DIR";
49
50/// Overrides [`AppPaths::data`]. Set by an Android host, and by tests.
51pub const DATA_DIR_ENV: &str = "RAHTI_NATIVE_DATA_DIR";
52/// Overrides [`AppPaths::config`].
53pub const CONFIG_DIR_ENV: &str = "RAHTI_NATIVE_CONFIG_DIR";
54/// Overrides [`AppPaths::cache`].
55pub const CACHE_DIR_ENV: &str = "RAHTI_NATIVE_CACHE_DIR";
56/// Overrides [`AppPaths::resources`] — where the package's read-only files
57/// were installed.
58pub const RESOURCE_DIR_ENV: &str = "RAHTI_NATIVE_RESOURCE_DIR";
59
60/// The file that records which version of the package staged the assets now in
61/// internal storage.
62pub const ASSET_STAMP: &str = ".rahti-assets";
63
64/// The resolved directories of one installation.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AppPaths {
67 identifier: String,
68 data: PathBuf,
69 config: PathBuf,
70 cache: PathBuf,
71 resources: PathBuf,
72}
73
74impl AppPaths {
75 /// The directories for `identifier`, from the environment or the platform
76 /// defaults.
77 ///
78 /// The four `RAHTI_NATIVE_*_DIR` variables win where they are set, which
79 /// is how an Android host passes in directories only the Java side knows
80 /// and how a test gets a temporary tree without touching the real one.
81 pub fn resolve(identifier: &str) -> Result<Self, NativeError> {
82 crate::config::check_identifier(identifier)
83 .map_err(|message| NativeError::new("paths", message))?;
84
85 let resources = match env_path(RESOURCE_DIR_ENV) {
86 Some(dir) => dir,
87 None => default_resource_dir()?,
88 };
89
90 let data = match env_path(DATA_DIR_ENV) {
91 Some(dir) => dir,
92 None => default_data_dir(identifier)?,
93 };
94
95 let config = env_path(CONFIG_DIR_ENV)
96 .or_else(|| default_config_dir(identifier))
97 .unwrap_or_else(|| data.join("config"));
98
99 let cache = env_path(CACHE_DIR_ENV).unwrap_or_else(|| data.join("cache"));
100
101 Ok(AppPaths {
102 identifier: identifier.to_string(),
103 data,
104 config,
105 cache,
106 resources,
107 })
108 }
109
110 /// The directories the host already knows.
111 ///
112 /// Android's are only reachable from the Java side, so the Tauri shell
113 /// asks Tauri for them and hands them over rather than this crate trying
114 /// to derive something it cannot see.
115 pub fn from_host(
116 identifier: &str,
117 data: impl Into<PathBuf>,
118 cache: impl Into<PathBuf>,
119 resources: impl Into<PathBuf>,
120 ) -> Result<Self, NativeError> {
121 crate::config::check_identifier(identifier)
122 .map_err(|message| NativeError::new("paths", message))?;
123
124 let data = data.into();
125 Ok(AppPaths {
126 identifier: identifier.to_string(),
127 config: data.join("config"),
128 cache: cache.into(),
129 resources: resources.into(),
130 data,
131 })
132 }
133
134 /// The application identifier these paths were built for.
135 pub fn identifier(&self) -> &str {
136 &self.identifier
137 }
138
139 /// Durable application data: the database, uploads, the session key, logs.
140 /// Survives until the user uninstalls or clears the application's data.
141 pub fn data(&self) -> &Path {
142 &self.data
143 }
144
145 /// User-visible configuration the application chooses to persist.
146 pub fn config(&self) -> &Path {
147 &self.config
148 }
149
150 /// Data the operating system may delete without asking.
151 pub fn cache(&self) -> &Path {
152 &self.cache
153 }
154
155 /// The package's read-only installed files. **Never written to.**
156 pub fn resources(&self) -> &Path {
157 &self.resources
158 }
159
160 /// Where the application's static assets are served from.
161 ///
162 /// Under `data`, not under `resources`, because Android's are not files
163 /// until [`stage_public_assets`] has copied them out of the package.
164 /// Windows could serve straight from the installation directory, and
165 /// deliberately does not: one path that behaves the same on both platforms
166 /// is worth more than one avoided copy of a few hundred kilobytes.
167 pub fn public(&self) -> PathBuf {
168 self.data.join("assets")
169 }
170
171 /// Where the assets are staged *from* — the copy inside the package.
172 pub fn bundled_public(&self) -> PathBuf {
173 self.resources.join("public")
174 }
175
176 /// The SQLite file, for an application whose database is local.
177 pub fn database(&self) -> PathBuf {
178 self.data.join("app.db")
179 }
180
181 /// Files the application saved on the user's behalf.
182 pub fn uploads(&self) -> PathBuf {
183 self.data.join("uploads")
184 }
185
186 /// Where a large upload is spooled while it arrives.
187 ///
188 /// Cache rather than data: the file exists for one request, and an
189 /// operating system that reclaims it between requests has taken nothing.
190 pub fn spill(&self) -> PathBuf {
191 self.cache.join("uploads")
192 }
193
194 /// Application logs.
195 pub fn logs(&self) -> PathBuf {
196 self.data.join("logs")
197 }
198
199 /// Short-lived scratch files.
200 pub fn temp(&self) -> PathBuf {
201 self.cache.join("tmp")
202 }
203
204 /// Files the application exports for the user to keep.
205 pub fn exports(&self) -> PathBuf {
206 self.data.join("exports")
207 }
208
209 /// The per-installation session key.
210 pub fn secret_file(&self) -> PathBuf {
211 self.data.join(crate::secret::SECRET_FILE)
212 }
213
214 /// Create every directory the application writes into.
215 ///
216 /// Idempotent, and called before anything else reads a path — a first
217 /// launch has none of them, and an SQLite file cannot be created in a
218 /// directory that is not there.
219 pub fn prepare(&self) -> Result<(), NativeError> {
220 for dir in [
221 self.data.clone(),
222 self.config.clone(),
223 self.cache.clone(),
224 self.public(),
225 self.uploads(),
226 self.spill(),
227 self.logs(),
228 self.temp(),
229 self.exports(),
230 ] {
231 std::fs::create_dir_all(&dir).map_err(|e| NativeError::io("paths", &dir, e))?;
232 }
233 Ok(())
234 }
235
236 /// A `DATABASE_URL` for the local SQLite file.
237 ///
238 /// Absolute, because a relative one resolves against a working directory
239 /// an installed application does not control. `mode=rwc` because a first
240 /// launch has no file yet.
241 pub fn sqlite_url(&self) -> String {
242 // Forward slashes on every platform: a backslash in a URL is not a
243 // path separator, and `C:\Users\…` arrives at SQLite as one long
244 // filename with no directories in it.
245 let path = self.database().display().to_string().replace('\\', "/");
246 format!("sqlite://{path}?mode=rwc")
247 }
248
249 /// Put the resolved paths where the application will read them.
250 ///
251 /// Called *before* `initialize_application`, because the generated router
252 /// reads `RAHTI_PUBLIC_DIR` while it is being built and an upload reads
253 /// `RAHTI_SPILL_DIR` on the first request that spills.
254 ///
255 /// `public` is passed rather than taken from [`Self::public`] because it
256 /// is the one path that is not always the packaged one: a `cargo rahti
257 /// native dev` run has no bundle to stage from and serves the project's
258 /// own `public/` directly, which is also what makes a stylesheet edit
259 /// visible without a rebuild. See [`resolve_public`].
260 ///
261 /// Set rather than defaulted: `std::env::set_var` overwrites, and that is
262 /// deliberate. An installed application inherits the environment of
263 /// whoever launched it, and a developer with `RAHTI_PUBLIC_DIR` exported
264 /// for their own checkout would otherwise have a shipped application
265 /// serving assets out of their source tree.
266 pub fn apply_environment(&self, public: &Path) {
267 // SAFETY: called by the native host before any task is spawned and
268 // before the router is built — the same single-threaded moment `main`
269 // sets anything else.
270 unsafe {
271 std::env::set_var(PUBLIC_DIR_ENV, public);
272 std::env::set_var(SPILL_DIR_ENV, self.spill());
273 }
274 }
275
276 /// Point `DATABASE_URL` at the local SQLite file.
277 ///
278 /// Separate from [`apply_environment`](Self::apply_environment) because it
279 /// is the one path decision a native package must not make on the
280 /// application's behalf. A project on PostgreSQL or MySQL has a database
281 /// somewhere else, and silently rewriting its connection string to a local
282 /// SQLite file would start the application against an empty database that
283 /// looks like a working one. See [`crate::DatabaseMode`].
284 pub fn apply_sqlite_database_url(&self) {
285 // SAFETY: as above.
286 unsafe {
287 std::env::set_var("DATABASE_URL", self.sqlite_url());
288 }
289 }
290}
291
292/// One file of the application's `public/`, compiled into the binary.
293pub struct EmbeddedAsset<'a> {
294 /// The path below `public/`, with `/` separators — `js/main.js`.
295 pub path: &'a str,
296 pub bytes: &'a [u8],
297}
298
299/// Write the embedded assets into internal storage, once per asset version.
300///
301/// ## Why the assets are in the binary
302///
303/// Because on Android there is no other portable way to get at them.
304///
305/// Tauri's bundler does copy `bundle.resources` into an Android package — into
306/// the APK's `assets/`, which is a zip entry rather than a file. And
307/// `app.path().resource_dir()` on Android does not return a directory at all:
308/// it returns the string `asset://localhost/`. So a `ServeDir` pointed at it
309/// serves nothing, `Path::is_dir` on it is false, and an application built
310/// that way starts, binds its port, opens its window, and 404s every
311/// stylesheet and the entire PulsePoint runtime.
312///
313/// Reading them out of the APK instead would mean the Android AssetManager,
314/// which means JNI, which means the platform-neutral half of this crate would
315/// stop being platform-neutral.
316///
317/// Embedding sidesteps all of it. The bytes are in the executable on both
318/// platforms, they are written to application storage on first launch, and one
319/// code path serves both. It costs the size of `public/` in the binary, which
320/// for a stylesheet, a runtime bundle and a favicon is a fair price for the
321/// alternative being "does not work".
322///
323/// Returns `true` when it wrote, `false` when the staged copy was already
324/// current. The version stamp and the replace-whole-tree rule are
325/// [`stage_public_assets`]'s, for the same reasons.
326pub fn stage_embedded_assets(
327 assets: &[EmbeddedAsset<'_>],
328 destination: &Path,
329 version: &str,
330) -> Result<bool, NativeError> {
331 let stamp = destination.join(ASSET_STAMP);
332
333 if std::fs::read_to_string(&stamp).is_ok_and(|current| current.trim() == version.trim()) {
334 return Ok(false);
335 }
336
337 if assets.is_empty() {
338 return Err(NativeError::at(
339 "assets",
340 destination,
341 "this package embeds no static assets, so every stylesheet and the browser \
342 runtime would 404.\n \
343 The shell embeds the project's `public/` at compile time — check that the \
344 directory exists and is not empty.",
345 ));
346 }
347
348 if destination.exists() {
349 std::fs::remove_dir_all(destination)
350 .map_err(|e| NativeError::io("assets", destination, e))?;
351 }
352
353 for asset in assets {
354 // Rejected rather than sanitized: these paths come from the project's
355 // own directory at compile time, so anything climbing out of the
356 // destination is a bug in the shell rather than input to defend
357 // against — and writing it anyway would put a file somewhere nobody
358 // asked for.
359 if asset.path.contains("..") || Path::new(asset.path).is_absolute() {
360 return Err(NativeError::at(
361 "assets",
362 asset.path,
363 "an embedded asset path leaves the asset directory",
364 ));
365 }
366
367 let target = destination.join(asset.path);
368 if let Some(parent) = target.parent() {
369 std::fs::create_dir_all(parent).map_err(|e| NativeError::io("assets", parent, e))?;
370 }
371 std::fs::write(&target, asset.bytes).map_err(|e| NativeError::io("assets", &target, e))?;
372 }
373
374 std::fs::write(&stamp, version).map_err(|e| NativeError::io("assets", &stamp, e))?;
375 Ok(true)
376}
377
378/// Copy the package's public assets into internal storage, once per asset version.
379///
380/// Android needs this: the files in an APK are entries in a zip, not paths a
381/// `ServeDir` can open. Windows does not need it and does it anyway, so that
382/// one code path serves both.
383///
384/// Returns `true` when it copied, `false` when the staged copy was already
385/// current.
386///
387/// ## What an upgrade does
388///
389/// The staged tree is *replaced*, not merged. A framework-owned asset that was
390/// renamed or deleted between versions would otherwise sit in internal storage
391/// forever, and a stale `pp-reactive-v2.min.js` beside a current `main.js` is
392/// a runtime that fails in ways nothing explains.
393///
394/// Replacing is safe because the destination is the application's asset
395/// directory and nothing else — [`AppPaths::public`] is a subdirectory of
396/// `data`, not `data` itself. The database, the uploads, the logs and the
397/// session key are siblings of it and are never touched.
398pub fn stage_public_assets(
399 source: &Path,
400 destination: &Path,
401 version: &str,
402) -> Result<bool, NativeError> {
403 let stamp = destination.join(ASSET_STAMP);
404
405 if std::fs::read_to_string(&stamp).is_ok_and(|current| current.trim() == version.trim()) {
406 return Ok(false);
407 }
408
409 if !source.is_dir() {
410 return Err(NativeError::at(
411 "assets",
412 source,
413 "the package has no public assets to stage",
414 ));
415 }
416
417 if destination.exists() {
418 std::fs::remove_dir_all(destination)
419 .map_err(|e| NativeError::io("assets", destination, e))?;
420 }
421 copy_tree(source, destination)?;
422
423 std::fs::write(&stamp, version).map_err(|e| NativeError::io("assets", &stamp, e))?;
424 Ok(true)
425}
426
427fn copy_tree(source: &Path, destination: &Path) -> Result<(), NativeError> {
428 std::fs::create_dir_all(destination).map_err(|e| NativeError::io("assets", destination, e))?;
429
430 let entries = std::fs::read_dir(source).map_err(|e| NativeError::io("assets", source, e))?;
431 for entry in entries {
432 let entry = entry.map_err(|e| NativeError::io("assets", source, e))?;
433 let from = entry.path();
434 let to = destination.join(entry.file_name());
435
436 let kind = entry
437 .file_type()
438 .map_err(|e| NativeError::io("assets", &from, e))?;
439 if kind.is_dir() {
440 copy_tree(&from, &to)?;
441 } else {
442 std::fs::copy(&from, &to).map_err(|e| NativeError::io("assets", &from, e))?;
443 }
444 }
445 Ok(())
446}
447
448/// A directory named by an environment variable, with blank counted as unset.
449fn env_path(name: &str) -> Option<PathBuf> {
450 let value = std::env::var(name).ok()?;
451 let value = value.trim();
452 (!value.is_empty()).then(|| PathBuf::from(value))
453}
454
455/// `%LOCALAPPDATA%\<identifier>` on Windows.
456///
457/// Local rather than roaming: a database and a cache of uploads are not things
458/// to copy across a domain at every sign-in, and `%APPDATA%` is where a
459/// managed network would put them.
460fn default_data_dir(identifier: &str) -> Result<PathBuf, NativeError> {
461 match Platform::current() {
462 Platform::Windows => Ok(required_env("LOCALAPPDATA")?.join(identifier)),
463 Platform::Android => Err(NativeError::new(
464 "paths",
465 format!(
466 "an Android package must be told where its files are: set {DATA_DIR_ENV}, \
467 or build the paths with `AppPaths::from_host`.\n \
468 Only the Java side knows the internal files directory, so it cannot be \
469 derived here."
470 ),
471 )),
472 // Not a packaging target. Resolved anyway so that this crate's tests
473 // run on a machine that is neither.
474 Platform::Other => {
475 let home = std::env::var_os("HOME")
476 .map(PathBuf::from)
477 .unwrap_or_else(std::env::temp_dir);
478 Ok(home.join(".local/share").join(identifier))
479 }
480 }
481}
482
483/// `%APPDATA%\<identifier>` on Windows, and nothing anywhere else — the
484/// caller falls back to `data/config`.
485fn default_config_dir(identifier: &str) -> Option<PathBuf> {
486 match Platform::current() {
487 Platform::Windows => std::env::var_os("APPDATA")
488 .map(PathBuf::from)
489 .map(|dir| dir.join(identifier)),
490 _ => None,
491 }
492}
493
494/// Where the package's own files were installed: the directory holding the
495/// executable.
496///
497/// A Tauri host overrides this with its resolved resource directory, which is
498/// the same place on Windows and a very different one on Android.
499fn default_resource_dir() -> Result<PathBuf, NativeError> {
500 let exe = std::env::current_exe()
501 .map_err(|e| NativeError::new("paths", format!("cannot locate the executable: {e}")))?;
502 Ok(exe
503 .parent()
504 .map(Path::to_path_buf)
505 .unwrap_or_else(|| PathBuf::from(".")))
506}
507
508fn required_env(name: &str) -> Result<PathBuf, NativeError> {
509 std::env::var_os(name).map(PathBuf::from).ok_or_else(|| {
510 NativeError::new(
511 "paths",
512 format!("{name} is not set, so there is nowhere to keep this application's data"),
513 )
514 })
515}