zng_env/
lib.rs

1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//!
4//! Process environment directories and unique name.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12use std::{
13    fs,
14    io::{self, BufRead},
15    path::{Path, PathBuf},
16    str::FromStr,
17};
18
19use semver::Version;
20use zng_txt::{ToTxt, Txt};
21use zng_unique_id::{lazy_static, lazy_static_init};
22mod process;
23pub use process::*;
24
25lazy_static! {
26    static ref ABOUT: About = About::fallback_name();
27}
28
29/// Inits process metadata, calls process start handlers and defines the process lifetime in `main`.
30///
31/// This **must** be called in main.
32///
33/// Init [`about`] an [`About`] for the process metadata. See [`on_process_start!`] for process start handlers.
34/// See [`on_process_exit`] for exit handlers called at the end of the `main` function.
35///
36/// # Process Start
37///
38/// A single Zng executable can be built with multiple components that spawn different instances
39/// of the executable that must run as different processes. If the current instance is requested
40/// by component `init!` runs it and exits the process, never returning flow to the normal main function.
41///
42/// ```
43/// # mod zng { pub mod env { pub use zng_env::*; } }
44/// fn main() {
45///     println!("print in all processes");
46///     zng::env::init!();
47///     println!("print only in the app-process");
48///
49///     // directories are available after `init!`.
50///     let _res = zng::env::res("");
51///
52///     // APP.defaults().run(...);
53///
54///     // on_exit handlers are called here
55/// }
56/// ```
57///
58/// # Web Start
59///
60/// WebAssembly builds (`target_arch = "wasm32"`) must share the app wasm module reference by setting the custom attribute
61/// `__zng_env_init_module` on the Javascript `window` object.
62///
63/// The `init!` call **will panic** if the attribute is not found.
64///
65/// ```html
66/// <script type="module">
67/// import init, * as my_app_wasm from './my_app.js';
68/// window.__zng_env_init_module = my_app_wasm;
69/// async function main() {
70///   await init();
71/// }
72/// main();
73/// </script>
74/// ```
75///
76/// The example above imports and runs an app built using [`wasm-pack`] with `--target web` options.
77///
78/// # Android Start
79///
80/// Android builds (`target_os = "android"`) receive an `AndroidApp` instance from the `android_main`. This type
81/// is tightly coupled with the view-process implementation and so it is defined by the `zng-view` crate. In builds
82/// feature `"view"` you must call `zng::view_process::default::android::init_android_app` just after `init!`.
83///
84/// ```
85/// # macro_rules! demo { () => {
86/// #[unsafe(no_mangle)]
87/// fn android_main(app: zng::view_process::default::android::AndroidApp) {
88///     zng::env::init!();
89///     zng::view_process::default::android::init_android_app(app);
90///     // zng::view_process::default::run_same_process(..);
91/// }
92/// # }}
93/// ```
94///
95/// See the [multi example] for more details on how to support Android and other platforms.
96///
97/// # Test Start
98///
99/// In test builds this macro may be called multiple times in the same process at the start of `#[test]` functions.
100///
101/// ```
102/// # macro_rules! demo { () => {
103/// #[test]
104/// fn foo() {
105///     zng::env::init!();
106///     let mut app = APP.defaults().run_headless(false);
107///     // ...
108/// }
109///
110/// #[test]
111/// fn bar() {
112///     zng::env::init!();
113///     // ...
114/// }
115/// # }}
116/// ```
117///
118/// Note that the process start handlers will still only run once on the first test, the process exit handlers
119/// **will not run**.
120///
121/// [`wasm-pack`]: https://crates.io/crates/wasm-pack
122/// [multi example]: https://github.com/zng-ui/zng/tree/main/examples#multi
123#[allow(clippy::test_attr_in_doctest)]
124#[macro_export]
125macro_rules! init {
126    () => {
127        let _on_main_exit = $crate::init_parse!($crate);
128    };
129}
130#[doc(hidden)]
131pub use zng_env_proc_macros::init_parse;
132
133#[doc(hidden)]
134pub fn init(about: About) -> Box<dyn std::any::Any> {
135    if !about.is_test {
136        if lazy_static_init(&ABOUT, about).is_err() {
137            panic!("env::init! already called\nnote: In `cfg(test)` builds init! can be called multiple times")
138        }
139        Box::new(process_init())
140    } else {
141        // in test
142        if lazy_static_init(&ABOUT, about).is_ok() {
143            Box::leak(Box::new(process_init()));
144        }
145        Box::new(())
146    }
147}
148
149/// Metadata about the app and main crate.
150///
151/// See [`about`] for more details.
152#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
153#[non_exhaustive]
154pub struct About {
155    /// package.name
156    ///
157    /// Cargo crate name for the entry crate.
158    pub pkg_name: Txt,
159    /// package.authors
160    ///
161    /// Cargo crate authors for the entry crate.
162    pub pkg_authors: Box<[Txt]>,
163
164    /// package.version
165    ///
166    /// Cargo crate version for the entry crate.
167    pub version: Version,
168
169    /// package.metadata.zng.about.app_id
170    ///
171    /// Fully qualified unique name for the application. Is a list of one or more dot-separated identifiers,
172    /// each identifier starts with letter, identifiers contains only letters, digits and underscore.
173    /// A reverse DNS name is recommended.
174    ///
175    /// If the metadata is not set an id is derived from `"qualifier"`, `org` and `app` values.
176    pub app_id: Txt,
177    /// package.metadata.zng.about.app
178    ///
179    /// App display name.
180    ///
181    /// If the metadata is not set the `pkg_name` is used.
182    pub app: Txt,
183    /// package.metadata.zng.about.org
184    ///
185    /// Organization display name.
186    ///
187    /// If the metadata is not set the first `pkg_authors` is used.
188    pub org: Txt,
189
190    /// package.description
191    ///
192    /// Short description of the app.
193    pub description: Txt,
194    /// package.homepage
195    ///
196    /// Valid website about the app.
197    pub homepage: Txt,
198
199    /// package.license
200    ///
201    /// License title of the app.
202    pub license: Txt,
203
204    /// If package.metadata.zng.about is set on the Cargo.toml manifest.
205    ///
206    /// The presence of this section is used by `cargo zng res` to find the main
207    /// crate if the workspace has multiple bin crates.
208    pub has_about: bool,
209
210    /// package.metadata.zng.about.*
211    ///
212    /// Any other unknown string metadata.
213    pub meta: Vec<(Txt, Txt)>,
214
215    /// If app was started in a `cfg(test)` build binary.
216    pub is_test: bool,
217}
218impl About {
219    /// The `pkg_name` in snake_case.
220    pub fn crate_name(&self) -> Txt {
221        self.pkg_name.replace('-', "_").into()
222    }
223
224    /// The first components of `app_id` except the last two.
225    ///
226    /// For app id `"br.com.company.product"` the qualifier is `"br.com"`. For id `"company.product"` the qualifier is empty.
227    pub fn qualifier(&self) -> Txt {
228        if let Some((_, v)) = self.meta.iter().find(|(k, _)| k == "qualifier") {
229            return v.clone();
230        }
231        self.try_qualifier().map(Txt::from_str).unwrap_or_default()
232    }
233    fn try_qualifier(&self) -> Option<&str> {
234        let last_dot = self.app_id.rfind('.')?;
235        let len = self.app_id[..last_dot].rfind('.')?;
236        Some(&self.app_id[..len])
237    }
238
239    /// Get the value.
240    ///
241    /// The `key` can be an entry of [`meta`], the name of a field or the name of a value method.
242    ///
243    /// [`meta`]: Self::meta
244    pub fn get(&self, key: &str) -> Option<Txt> {
245        match key {
246            "pkg_name" => Some(self.pkg_name.clone()),
247            "pkg_authors" => {
248                let mut r = String::new();
249                let mut sep = "";
250                for a in &self.pkg_authors {
251                    r.push_str(sep);
252                    r.push_str(a);
253                    sep = ", ";
254                }
255                Some(r.into())
256            }
257            "version" => Some(self.version.to_txt()),
258            "app_id" => Some(self.app_id.clone()),
259            "app" => Some(self.app.clone()),
260            "org" => Some(self.org.clone()),
261            "description" => Some(self.description.clone()),
262            "homepage" => Some(self.homepage.clone()),
263            "license" => Some(self.license.clone()),
264            "crate_name" => Some(self.crate_name()),
265            "qualifier" => Some(self.qualifier()),
266            _ => {
267                for (k, v) in self.meta.iter() {
268                    if k == key {
269                        return Some(v.clone());
270                    }
271                }
272                None
273            }
274        }
275    }
276}
277impl About {
278    fn fallback_name() -> Self {
279        Self {
280            pkg_name: Txt::from_static(""),
281            pkg_authors: Box::new([]),
282            version: Version::new(0, 0, 0),
283            app: fallback_name(),
284            org: Txt::from_static(""),
285            description: Txt::from_static(""),
286            homepage: Txt::from_static(""),
287            license: Txt::from_static(""),
288            has_about: false,
289            app_id: fallback_id(),
290            meta: vec![],
291            is_test: false,
292        }
293    }
294
295    /// Parse a Cargo.toml string.
296    #[cfg(feature = "parse")]
297    pub fn parse_manifest(cargo_toml: &str) -> Result<Self, toml::de::Error> {
298        #[derive(serde::Deserialize)]
299        struct Manifest {
300            package: Package,
301        }
302        #[derive(serde::Deserialize)]
303        struct Package {
304            name: Txt,
305            version: Version,
306            description: Option<Txt>,
307            homepage: Option<Txt>,
308            license: Option<Txt>,
309            authors: Option<Box<[Txt]>>,
310            metadata: Option<Metadata>,
311        }
312        #[derive(serde::Deserialize)]
313        struct Metadata {
314            zng: Option<Zng>,
315        }
316        #[derive(serde::Deserialize)]
317        struct Zng {
318            about: toml::Table,
319        }
320
321        let m: Manifest = toml::from_str(cargo_toml)?;
322        let mut about = About {
323            pkg_name: m.package.name,
324            pkg_authors: m.package.authors.unwrap_or_default(),
325            version: m.package.version,
326            description: m.package.description.unwrap_or_default(),
327            homepage: m.package.homepage.unwrap_or_default(),
328            license: m.package.license.unwrap_or_default(),
329            app: Txt::from_static(""),
330            org: Txt::from_static(""),
331            app_id: Txt::from_static(""),
332            has_about: false,
333            meta: vec![],
334            is_test: false,
335        };
336        if let Some(zng) = m.package.metadata.and_then(|m| m.zng)
337            && !zng.about.is_empty()
338        {
339            let s = |key: &str| match zng.about.get(key) {
340                Some(toml::Value::String(s)) => Txt::from_str(s.as_str()),
341                _ => Txt::from_static(""),
342            };
343            about.has_about = true;
344            about.app = s("app");
345            about.org = s("org");
346            about.app_id = clean_id(&s("app_id"));
347            for (k, v) in zng.about {
348                if let toml::Value::String(v) = v
349                    && !["app", "org", "app_id"].contains(&k.as_str())
350                {
351                    about.meta.push((k.into(), v.into()));
352                }
353            }
354        }
355        if about.app.is_empty() {
356            about.app = about.pkg_name.clone();
357        }
358        if about.org.is_empty() {
359            about.org = about.pkg_authors.first().cloned().unwrap_or_default();
360        }
361        if about.app_id.is_empty() {
362            about.app_id = clean_id(&format!(
363                "{}.{}.{}",
364                about.get("qualifier").unwrap_or_default(),
365                about.org,
366                about.app
367            ));
368        }
369        Ok(about)
370    }
371
372    #[doc(hidden)]
373    #[expect(clippy::too_many_arguments)]
374    pub fn macro_new(
375        pkg_name: &'static str,
376        pkg_authors: &[&'static str],
377        (major, minor, patch, pre, build): (u64, u64, u64, &'static str, &'static str),
378        app_id: &'static str,
379        app: &'static str,
380        org: &'static str,
381        description: &'static str,
382        homepage: &'static str,
383        license: &'static str,
384        has_about: bool,
385        meta: &[(&'static str, &'static str)],
386        is_test: bool,
387    ) -> Self {
388        Self {
389            pkg_name: Txt::from_static(pkg_name),
390            pkg_authors: pkg_authors.iter().copied().map(Txt::from_static).collect(),
391            version: {
392                let mut v = Version::new(major, minor, patch);
393                v.pre = semver::Prerelease::from_str(pre).unwrap();
394                v.build = semver::BuildMetadata::from_str(build).unwrap();
395                v
396            },
397            app_id: Txt::from_static(app_id),
398            app: Txt::from_static(app),
399            org: Txt::from_static(org),
400            meta: meta.iter().map(|(k, v)| (Txt::from_static(k), Txt::from_static(v))).collect(),
401            description: Txt::from_static(description),
402            homepage: Txt::from_static(homepage),
403            license: Txt::from_static(license),
404            has_about,
405            is_test,
406        }
407    }
408}
409
410/// Gets metadata about the application.
411///
412/// The app must call [`init!`] at the beginning of the process, otherwise the metadata will fallback
413/// to just a name extracted from the current executable file path.
414///
415/// See the [`directories::ProjectDirs::from`] documentation for more details on how this metadata is
416/// used to create/find the app data directories.
417///
418/// [`directories::ProjectDirs::from`]: https://docs.rs/directories/5.0/directories/struct.ProjectDirs.html#method.from
419pub fn about() -> &'static About {
420    &ABOUT
421}
422
423fn fallback_name() -> Txt {
424    let exe = current_exe();
425    let exe_name = exe.file_name().unwrap().to_string_lossy();
426    let name = exe_name.split('.').find(|p| !p.is_empty()).unwrap();
427    Txt::from_str(name)
428}
429
430fn fallback_id() -> Txt {
431    let exe = current_exe();
432    let exe_name = exe.file_name().unwrap().to_string_lossy();
433    clean_id(&exe_name)
434}
435
436/// * At least one identifier, dot-separated.
437/// * Each identifier must contain ASCII letters, ASCII digits and underscore only.
438/// * Each identifier must start with a letter.
439/// * All lowercase.
440fn clean_id(raw: &str) -> Txt {
441    let mut r = String::new();
442    let mut sep = "";
443    for i in raw.split('.') {
444        let i = i.trim();
445        if i.is_empty() {
446            continue;
447        }
448        r.push_str(sep);
449        for (i, c) in i.trim().char_indices() {
450            if i == 0 {
451                if !c.is_ascii_alphabetic() {
452                    r.push('i');
453                } else {
454                    r.push(c.to_ascii_lowercase());
455                }
456            } else if c.is_ascii_alphanumeric() || c == '_' {
457                r.push(c.to_ascii_lowercase());
458            } else {
459                r.push('_');
460            }
461        }
462        sep = ".";
463    }
464    r.into()
465}
466
467/// Gets a path relative to the package binaries.
468///
469/// * In Wasm returns `./`, as in the relative URL.
470/// * In all other platforms returns `std::env::current_exe().parent()`.
471///
472/// # Panics
473///
474/// Panics if [`std::env::current_exe`] returns an error or has no parent directory.
475pub fn bin(relative_path: impl AsRef<Path>) -> PathBuf {
476    BIN.join(relative_path)
477}
478lazy_static! {
479    static ref BIN: PathBuf = find_bin();
480}
481
482fn find_bin() -> PathBuf {
483    if cfg!(target_arch = "wasm32") {
484        PathBuf::from("./")
485    } else {
486        current_exe().parent().expect("current_exe path parent is required").to_owned()
487    }
488}
489
490/// Gets a path relative to the package resources.
491///
492/// * The res dir can be set by [`init_res`] before any env dir is used.
493/// * In Android returns `android_internal("res")`, assumes the package assets are extracted to this directory.
494/// * In Linux, macOS and Windows if a file `bin/current_exe_name.res-dir` is found the first non-empty and non-comment (#) line
495///   defines the res path.
496/// * In `cfg(debug_assertions)` builds returns `res`.
497/// * In Wasm returns `./res`, as in the relative URL.
498/// * In macOS returns `bin("../Resources")`, assumes the package is deployed using a desktop `.app` folder.
499/// * In all other Unix systems returns `bin("../share/current_exe_name")`, assumes the package is deployed
500///   using a Debian package.
501/// * In Windows returns `bin("../res")`. Note that there is no Windows standard, make sure to install
502///   the project using this structure.
503///
504/// # Built Resources
505///
506/// In `cfg(any(debug_assertions, feature="built_res"))` builds if the `target/res/{relative_path}` path exists it
507/// is returned instead. This is useful during development when the app depends on res that are generated locally and not
508/// included in version control.
509///
510/// Note that the built resources must be packaged with the other res at the same relative location, so that release builds can find them.
511///
512/// # Android
513///
514/// Unfortunately Android does not provide file system access to the bundled resources, you must use the `ndk::asset::AssetManager` to
515/// request files that are decompressed on demand from the APK file. We recommend extracting all cross-platform assets once on startup
516/// to avoid having to implement special Android handling for each resource usage. See [`android_install_res`] for more details.
517pub fn res(relative_path: impl AsRef<Path>) -> PathBuf {
518    res_impl(relative_path.as_ref())
519}
520#[cfg(all(
521    any(debug_assertions, feature = "built_res"),
522    not(any(target_os = "android", target_arch = "wasm32", target_os = "ios")),
523))]
524fn res_impl(relative_path: &Path) -> PathBuf {
525    let built = BUILT_RES.join(relative_path);
526    if built.exists() {
527        return built;
528    }
529
530    RES.join(relative_path)
531}
532#[cfg(not(all(
533    any(debug_assertions, feature = "built_res"),
534    not(any(target_os = "android", target_arch = "wasm32", target_os = "ios")),
535)))]
536fn res_impl(relative_path: &Path) -> PathBuf {
537    RES.join(relative_path)
538}
539
540/// Helper function for adapting Android assets to the cross-platform [`res`] API.
541///
542/// To implement Android resource extraction, bundle the resources in a tar that is itself bundled in `assets/res.tar` inside the APK.
543/// On startup, call this function, it handles resources extraction and versioning.
544///
545/// # Examples
546///
547/// ```
548/// # macro_rules! demo { () => {
549/// #[unsafe(no_mangle)]
550/// fn android_main(app: zng::view_process::default::android::AndroidApp) {
551///     zng::env::init!();
552///     zng::view_process::default::android::init_android_app(app.clone());
553///     zng::env::android_install_res(|| app.asset_manager().open(c"res.tar"));
554///     // zng::view_process::default::run_same_process(..);
555/// }
556/// # }}
557/// ```
558///
559/// The `open_res` closure is only called if this is the first instance of the current app version on the device, or if the user
560/// cleared all app data.
561///
562/// The resources are installed in the [`res`] directory, if the tar archive has only a root dir named `res` it is stripped.
563/// This function assumes that it is the only app component that writes to this directory.
564///
565/// Note that the tar file is not compressed, because the APK already compresses it. The `cargo zng res` tool `.zr-apk`
566/// tar resources by default, simply place the resources in `/assets/res/`.
567pub fn android_install_res<Asset: std::io::Read>(open_res: impl FnOnce() -> Option<Asset>) {
568    #[cfg(target_os = "android")]
569    {
570        let version = res(format!(".zng-env.res.{}", about().version));
571        if !version.exists() {
572            if let Some(res) = open_res() {
573                if let Err(e) = install_res(version, res) {
574                    tracing::error!("res install failed, {e}");
575                }
576            }
577        }
578    }
579    // cfg not applied to function so it shows on docs
580    #[cfg(not(target_os = "android"))]
581    let _ = open_res;
582}
583#[cfg(target_os = "android")]
584fn install_res(version: PathBuf, res: impl std::io::Read) -> std::io::Result<()> {
585    let res_path = version.parent().unwrap();
586    let _ = fs::remove_dir_all(res_path);
587    fs::create_dir(res_path)?;
588
589    let mut res = tar::Archive::new(res);
590    res.unpack(res_path)?;
591
592    // rename res/res to res if it is the only entry in res
593    let mut needs_pop = false;
594    for (i, entry) in fs::read_dir(&res_path)?.take(2).enumerate() {
595        needs_pop = i == 0 && entry?.file_name() == "res";
596    }
597    if needs_pop {
598        let tmp = res_path.parent().unwrap().join("res-tmp");
599        fs::rename(res_path.join("res"), &tmp)?;
600        fs::rename(tmp, res_path)?;
601    }
602
603    fs::File::create(&version)?;
604
605    Ok(())
606}
607
608/// Sets a custom [`res`] path.
609///
610/// # Panics
611///
612/// Panics if not called at the beginning of the process.
613pub fn init_res(path: impl Into<PathBuf>) {
614    if lazy_static_init(&RES, path.into()).is_err() {
615        panic!("cannot `init_res`, `res` has already inited")
616    }
617}
618
619/// Sets a custom path for the "built resources" override checked by [`res`] in debug builds.
620///
621/// # Panics
622///
623/// Panics if not called at the beginning of the process.
624#[cfg(any(debug_assertions, feature = "built_res"))]
625pub fn init_built_res(path: impl Into<PathBuf>) {
626    if lazy_static_init(&BUILT_RES, path.into()).is_err() {
627        panic!("cannot `init_built_res`, `res` has already inited")
628    }
629}
630
631lazy_static! {
632    static ref RES: PathBuf = find_res();
633
634    #[cfg(any(debug_assertions, feature = "built_res"))]
635    static ref BUILT_RES: PathBuf = PathBuf::from("target/res");
636}
637#[cfg(target_os = "android")]
638fn find_res() -> PathBuf {
639    android_internal("res")
640}
641#[cfg(not(target_os = "android"))]
642fn find_res() -> PathBuf {
643    #[cfg(not(target_arch = "wasm32"))]
644    if let Ok(mut p) = std::env::current_exe() {
645        p.set_extension("res-dir");
646        if let Ok(dir) = read_line(&p) {
647            return bin(dir);
648        }
649    }
650    if cfg!(debug_assertions) {
651        PathBuf::from("res")
652    } else if cfg!(target_arch = "wasm32") {
653        PathBuf::from("./res")
654    } else if cfg!(windows) {
655        bin("../res")
656    } else if cfg!(target_os = "macos") {
657        bin("../Resources")
658    } else if cfg!(target_family = "unix") {
659        let c = current_exe();
660        bin(format!("../share/{}", c.file_name().unwrap().to_string_lossy()))
661    } else {
662        panic!(
663            "resources dir not specified for platform {}, use a 'bin/current_exe_name.res-dir' file to specify an alternative",
664            std::env::consts::OS
665        )
666    }
667}
668
669/// Gets a path relative to the user config directory for the app.
670///
671/// * The config dir can be set by [`init_config`] before any env dir is used.
672/// * In Android returns `android_internal("config")`.
673/// * In Linux, macOS and Windows if a file in `res("config-dir")` is found the first non-empty and non-comment (#) line
674///   defines the res path.
675/// * In `cfg(debug_assertions)` builds returns `target/tmp/dev_config/`.
676/// * In all platforms attempts [`directories::ProjectDirs::config_dir`] and panic if it fails.
677/// * If the config dir selected by the previous method contains a `"config-dir"` file it will be
678///   used to redirect to another config dir, you can use this to implement config migration. Redirection only happens once.
679///
680/// The config directory is created if it is missing, checks once on init or first use.
681///
682/// [`directories::ProjectDirs::config_dir`]: https://docs.rs/directories/5.0/directories/struct.ProjectDirs.html#method.config_dir
683pub fn config(relative_path: impl AsRef<Path>) -> PathBuf {
684    CONFIG.join(relative_path)
685}
686
687/// Sets a custom [`original_config`] path.
688///
689/// # Panics
690///
691/// Panics if not called at the beginning of the process.
692pub fn init_config(path: impl Into<PathBuf>) {
693    if lazy_static_init(&ORIGINAL_CONFIG, path.into()).is_err() {
694        panic!("cannot `init_config`, `original_config` has already inited")
695    }
696}
697
698/// Config path before migration.
699///
700/// If this is equal to [`config`] the config has not migrated.
701pub fn original_config() -> PathBuf {
702    ORIGINAL_CONFIG.clone()
703}
704lazy_static! {
705    static ref ORIGINAL_CONFIG: PathBuf = find_config();
706}
707
708/// Copies all config to `new_path` and saves it as the config path.
709///
710/// If copying and saving path succeeds make a best effort to wipe the previous config dir. If copy and save fails
711/// makes a best effort to undo already made copies.
712///
713/// The `new_path` must not exist or be empty.
714pub fn migrate_config(new_path: impl AsRef<Path>) -> io::Result<()> {
715    migrate_config_impl(new_path.as_ref())
716}
717fn migrate_config_impl(new_path: &Path) -> io::Result<()> {
718    let prev_path = CONFIG.as_path();
719
720    if prev_path == new_path {
721        return Ok(());
722    }
723
724    let original_path = ORIGINAL_CONFIG.as_path();
725    let is_return = new_path == original_path;
726
727    if !is_return && dir_exists_not_empty(new_path) {
728        return Err(io::Error::new(
729            io::ErrorKind::AlreadyExists,
730            "can only migrate to new dir or empty dir",
731        ));
732    }
733    let created = !new_path.exists();
734    if created {
735        fs::create_dir_all(new_path)?;
736    }
737
738    let migrate = |from: &Path, to: &Path| {
739        copy_dir_all(from, to)?;
740        if fs::remove_dir_all(from).is_ok() {
741            fs::create_dir(from)?;
742        }
743
744        let redirect = ORIGINAL_CONFIG.join("config-dir");
745        if is_return {
746            fs::remove_file(redirect)
747        } else {
748            fs::write(redirect, to.display().to_string().as_bytes())
749        }
750    };
751
752    if let Err(e) = migrate(prev_path, new_path) {
753        if fs::remove_dir_all(new_path).is_ok() && !created {
754            let _ = fs::create_dir(new_path);
755        }
756        return Err(e);
757    }
758
759    tracing::info!("changed config dir to `{}`", new_path.display());
760
761    Ok(())
762}
763
764fn copy_dir_all(from: &Path, to: &Path) -> io::Result<()> {
765    for entry in fs::read_dir(from)? {
766        let from = entry?.path();
767        if from.is_dir() {
768            let to = to.join(from.file_name().unwrap());
769            fs::create_dir(&to)?;
770            copy_dir_all(&from, &to)?;
771        } else if from.is_file() {
772            let to = to.join(from.file_name().unwrap());
773            fs::copy(&from, &to)?;
774        } else {
775            continue;
776        }
777    }
778    Ok(())
779}
780
781lazy_static! {
782    static ref CONFIG: PathBuf = redirect_config(original_config());
783}
784
785#[cfg(target_os = "android")]
786fn find_config() -> PathBuf {
787    android_internal("config")
788}
789#[cfg(not(target_os = "android"))]
790fn find_config() -> PathBuf {
791    let cfg_dir = res("config-dir");
792    if let Ok(dir) = read_line(&cfg_dir) {
793        return res(dir);
794    }
795
796    if cfg!(debug_assertions) {
797        return PathBuf::from("target/tmp/dev_config/");
798    }
799
800    let a = about();
801    if let Some(dirs) = directories::ProjectDirs::from(&a.qualifier(), &a.org, &a.app) {
802        dirs.config_dir().to_owned()
803    } else {
804        panic!(
805            "config dir not specified for platform {}, use a '{}' file to specify an alternative",
806            std::env::consts::OS,
807            cfg_dir.display(),
808        )
809    }
810}
811fn redirect_config(cfg: PathBuf) -> PathBuf {
812    if cfg!(target_arch = "wasm32") {
813        return cfg;
814    }
815
816    if let Ok(dir) = read_line(&cfg.join("config-dir")) {
817        let mut dir = PathBuf::from(dir);
818        if dir.is_relative() {
819            dir = cfg.join(dir);
820        }
821        if dir.exists() {
822            let test_path = dir.join(".zng-config-test");
823            if let Err(e) = fs::create_dir_all(&dir)
824                .and_then(|_| fs::write(&test_path, "# check write access"))
825                .and_then(|_| fs::remove_file(&test_path))
826            {
827                eprintln!("error writing to migrated `{}`, {e}", dir.display());
828                tracing::error!("error writing to migrated `{}`, {e}", dir.display());
829                return cfg;
830            }
831        } else if let Err(e) = fs::create_dir_all(&dir) {
832            eprintln!("error creating migrated `{}`, {e}", dir.display());
833            tracing::error!("error creating migrated `{}`, {e}", dir.display());
834            return cfg;
835        }
836        dir
837    } else {
838        create_dir_opt(cfg)
839    }
840}
841
842fn create_dir_opt(dir: PathBuf) -> PathBuf {
843    if let Err(e) = std::fs::create_dir_all(&dir) {
844        eprintln!("error creating `{}`, {e}", dir.display());
845        tracing::error!("error creating `{}`, {e}", dir.display());
846    }
847    dir
848}
849
850/// Gets a path relative to the cache directory for the app.
851///
852/// * The cache dir can be set by [`init_cache`] before any env dir is used.
853/// * In Android returns `android_internal("cache")`.
854/// * In Linux, macOS and Windows if a file `config("cache-dir")` is found the first non-empty and non-comment (#) line
855///   defines the res path.
856/// * In `cfg(debug_assertions)` builds returns `target/tmp/dev_cache/`.
857/// * In all platforms attempts [`directories::ProjectDirs::cache_dir`] and panic if it fails.
858///
859/// The cache dir is created if it is missing, checks once on init or first use.
860///
861/// [`directories::ProjectDirs::cache_dir`]: https://docs.rs/directories/5.0/directories/struct.ProjectDirs.html#method.cache_dir
862pub fn cache(relative_path: impl AsRef<Path>) -> PathBuf {
863    CACHE.join(relative_path)
864}
865
866/// Sets a custom [`cache`] path.
867///
868/// # Panics
869///
870/// Panics if not called at the beginning of the process.
871pub fn init_cache(path: impl Into<PathBuf>) {
872    match lazy_static_init(&CACHE, path.into()) {
873        Ok(p) => {
874            create_dir_opt(p.to_owned());
875        }
876        Err(_) => panic!("cannot `init_cache`, `cache` has already inited"),
877    }
878}
879
880/// Removes all cache files possible.
881///
882/// Continues removing after the first fail, returns the last error.
883pub fn clear_cache() -> io::Result<()> {
884    best_effort_clear(CACHE.as_path())
885}
886fn best_effort_clear(path: &Path) -> io::Result<()> {
887    let mut error = None;
888
889    match fs::read_dir(path) {
890        Ok(cache) => {
891            for entry in cache {
892                match entry {
893                    Ok(e) => {
894                        let path = e.path();
895                        if path.is_dir() {
896                            if fs::remove_dir_all(&path).is_err() {
897                                match best_effort_clear(&path) {
898                                    Ok(()) => {
899                                        if let Err(e) = fs::remove_dir(&path) {
900                                            error = Some(e)
901                                        }
902                                    }
903                                    Err(e) => {
904                                        error = Some(e);
905                                    }
906                                }
907                            }
908                        } else if path.is_file()
909                            && let Err(e) = fs::remove_file(&path)
910                        {
911                            error = Some(e);
912                        }
913                    }
914                    Err(e) => {
915                        error = Some(e);
916                    }
917                }
918            }
919        }
920        Err(e) => {
921            error = Some(e);
922        }
923    }
924
925    match error {
926        Some(e) => Err(e),
927        None => Ok(()),
928    }
929}
930
931/// Save `new_path` as the new cache path and make a best effort to move existing cache files.
932///
933/// Note that the move failure is not considered an error (it is only logged), the app is expected to
934/// rebuild missing cache entries.
935///
936/// Note that [`cache`] will still point to the previous path on success, the app must be restarted to use the new cache.
937///
938/// The `new_path` must not exist or be empty.
939pub fn migrate_cache(new_path: impl AsRef<Path>) -> io::Result<()> {
940    migrate_cache_impl(new_path.as_ref())
941}
942fn migrate_cache_impl(new_path: &Path) -> io::Result<()> {
943    if dir_exists_not_empty(new_path) {
944        return Err(io::Error::new(
945            io::ErrorKind::AlreadyExists,
946            "can only migrate to new dir or empty dir",
947        ));
948    }
949    fs::create_dir_all(new_path)?;
950    let write_test = new_path.join(".zng-cache");
951    fs::write(&write_test, "# zng cache dir".as_bytes())?;
952    fs::remove_file(&write_test)?;
953
954    fs::write(config("cache-dir"), new_path.display().to_string().as_bytes())?;
955
956    tracing::info!("changed cache dir to `{}`", new_path.display());
957
958    let prev_path = CACHE.as_path();
959    if prev_path == new_path {
960        return Ok(());
961    }
962    if let Err(e) = best_effort_move(prev_path, new_path) {
963        eprintln!("failed to migrate all cache files, {e}");
964        tracing::error!("failed to migrate all cache files, {e}");
965    }
966
967    Ok(())
968}
969
970fn dir_exists_not_empty(dir: &Path) -> bool {
971    match fs::read_dir(dir) {
972        Ok(dir) => {
973            for entry in dir {
974                match entry {
975                    Ok(_) => return true,
976                    Err(e) => {
977                        if e.kind() != io::ErrorKind::NotFound {
978                            return true;
979                        }
980                    }
981                }
982            }
983            false
984        }
985        Err(e) => e.kind() != io::ErrorKind::NotFound,
986    }
987}
988
989fn best_effort_move(from: &Path, to: &Path) -> io::Result<()> {
990    let mut error = None;
991
992    match fs::read_dir(from) {
993        Ok(cache) => {
994            for entry in cache {
995                match entry {
996                    Ok(e) => {
997                        let from = e.path();
998                        if from.is_dir() {
999                            let to = to.join(from.file_name().unwrap());
1000                            if let Err(e) = fs::rename(&from, &to).or_else(|_| {
1001                                fs::create_dir(&to)?;
1002                                best_effort_move(&from, &to)?;
1003                                fs::remove_dir(&from)
1004                            }) {
1005                                error = Some(e)
1006                            }
1007                        } else if from.is_file() {
1008                            let to = to.join(from.file_name().unwrap());
1009                            if let Err(e) = fs::rename(&from, &to).or_else(|_| {
1010                                fs::copy(&from, &to)?;
1011                                fs::remove_file(&from)
1012                            }) {
1013                                error = Some(e);
1014                            }
1015                        }
1016                    }
1017                    Err(e) => {
1018                        error = Some(e);
1019                    }
1020                }
1021            }
1022        }
1023        Err(e) => {
1024            error = Some(e);
1025        }
1026    }
1027
1028    match error {
1029        Some(e) => Err(e),
1030        None => Ok(()),
1031    }
1032}
1033
1034lazy_static! {
1035    static ref CACHE: PathBuf = create_dir_opt(find_cache());
1036}
1037#[cfg(target_os = "android")]
1038fn find_cache() -> PathBuf {
1039    android_internal("cache")
1040}
1041#[cfg(not(target_os = "android"))]
1042fn find_cache() -> PathBuf {
1043    let cache_dir = config("cache-dir");
1044    if let Ok(dir) = read_line(&cache_dir) {
1045        return config(dir);
1046    }
1047
1048    if cfg!(debug_assertions) {
1049        return PathBuf::from("target/tmp/dev_cache/");
1050    }
1051
1052    let a = about();
1053    if let Some(dirs) = directories::ProjectDirs::from(&a.qualifier(), &a.org, &a.app) {
1054        dirs.cache_dir().to_owned()
1055    } else {
1056        panic!(
1057            "cache dir not specified for platform {}, use a '{}' file to specify an alternative",
1058            std::env::consts::OS,
1059            cache_dir.display(),
1060        )
1061    }
1062}
1063
1064fn current_exe() -> PathBuf {
1065    std::env::current_exe().expect("current_exe path is required")
1066}
1067
1068fn read_line(path: &Path) -> io::Result<String> {
1069    let file = fs::File::open(path)?;
1070    for line in io::BufReader::new(file).lines() {
1071        let line = line?;
1072        let line = line.trim();
1073        if line.starts_with('#') {
1074            continue;
1075        }
1076        return Ok(line.into());
1077    }
1078    Err(io::Error::new(io::ErrorKind::UnexpectedEof, "no uncommented line"))
1079}
1080
1081#[cfg(target_os = "android")]
1082mod android {
1083    use super::*;
1084
1085    lazy_static! {
1086        static ref ANDROID_PATHS: [PathBuf; 2] = [PathBuf::new(), PathBuf::new()];
1087    }
1088
1089    /// Initialize the Android app paths.
1090    ///
1091    /// This is called by `init_android_app` provided by view-process implementers.
1092    pub fn init_android_paths(internal: PathBuf, external: PathBuf) {
1093        if lazy_static_init(&ANDROID_PATHS, [internal, external]).is_err() {
1094            panic!("cannot `init_android_paths`, already inited")
1095        }
1096    }
1097
1098    /// Gets a path relative to the internal storage reserved for the app.
1099    ///
1100    /// Prefer using [`config`] or [`cache`] over this directly.
1101    pub fn android_internal(relative_path: impl AsRef<Path>) -> PathBuf {
1102        ANDROID_PATHS[0].join(relative_path)
1103    }
1104
1105    /// Gets a path relative to the external storage reserved for the app.
1106    ///
1107    /// This directory is user accessible.
1108    pub fn android_external(relative_path: impl AsRef<Path>) -> PathBuf {
1109        ANDROID_PATHS[1].join(relative_path)
1110    }
1111}
1112#[cfg(target_os = "android")]
1113pub use android::*;
1114
1115#[cfg(test)]
1116mod tests {
1117    use crate::*;
1118
1119    #[test]
1120    fn parse_manifest() {
1121        init!();
1122        let a = about();
1123        assert_eq!(a.pkg_name, "zng-env");
1124        assert_eq!(a.app, "zng-env");
1125        assert_eq!(&a.pkg_authors[..], &[Txt::from("The Zng Project Developers")]);
1126        assert_eq!(a.org, "The Zng Project Developers");
1127    }
1128}