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
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 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 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}