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