Skip to main content

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}