diff --git a/.changes/json5.md b/.changes/json5.md new file mode 100644 index 000000000..00a934e88 --- /dev/null +++ b/.changes/json5.md @@ -0,0 +1,21 @@ +--- +"tauri": patch +"tauri-build": patch +"tauri-codegen": patch +"tauri-macros": patch +"tauri-utils": patch +"cli.rs": patch +--- + +Adds support for using JSON5 format for the `tauri.conf.json` file, along with also supporting the `.json5` extension. + +Here is the logic flow that determines if JSON or JSON5 will be used to parse the config: + +1. Check if `tauri.conf.json` exists + a. Parse it with `serde_json` + b. Parse it with `json5` if `serde_json` fails + c. Return original `serde_json` error if all above steps failed +2. Check if `tauri.conf.json5` exists + a. Parse it with `json5` + b. Return error if all above steps failed +3. Return error if all above steps failed diff --git a/core/tauri-build/Cargo.toml b/core/tauri-build/Cargo.toml index dc601babf..6ed4b8fc7 100644 --- a/core/tauri-build/Cargo.toml +++ b/core/tauri-build/Cargo.toml @@ -20,9 +20,9 @@ rustdoc-args = [ "--cfg", "doc_cfg" ] anyhow = "1" quote = { version = "1", optional = true } tauri-codegen = { version = "1.0.0-beta.4", path = "../tauri-codegen", optional = true } -serde_json = "1.0" tauri-utils = { version = "1.0.0-beta.0", path = "../tauri-utils", features = [ "build" ] } cargo_toml = "0.10" +serde_json = "1" [target."cfg(windows)".dependencies] winres = "0.1" @@ -30,3 +30,4 @@ winres = "0.1" [features] codegen = [ "tauri-codegen", "quote" ] isolation = ["tauri-codegen/isolation", "tauri-utils/isolation"] +config-json5 = [ "tauri-utils/config-json5" ] diff --git a/core/tauri-build/src/codegen/context.rs b/core/tauri-build/src/codegen/context.rs index dcf89708a..29356b098 100644 --- a/core/tauri-build/src/codegen/context.rs +++ b/core/tauri-build/src/codegen/context.rs @@ -120,7 +120,7 @@ impl CodegenContext { ) })?; - writeln!(&mut file, "{}", code).with_context(|| { + writeln!(file, "{}", code).with_context(|| { format!( "Unable to write tokenstream to out file during tauri-build {}", out.display() diff --git a/core/tauri-build/src/lib.rs b/core/tauri-build/src/lib.rs index 86b0bb473..3663d5366 100644 --- a/core/tauri-build/src/lib.rs +++ b/core/tauri-build/src/lib.rs @@ -109,13 +109,20 @@ pub fn build() { pub fn try_build(attributes: Attributes) -> Result<()> { use anyhow::anyhow; use cargo_toml::{Dependency, Manifest}; - use std::fs::read_to_string; use tauri_utils::config::Config; - println!("cargo:rerun-if-changed=tauri.conf.json"); println!("cargo:rerun-if-changed=src/Cargo.toml"); + println!("cargo:rerun-if-changed=tauri.conf.json"); + #[cfg(feature = "config-json5")] + println!("cargo:rerun-if-changed=tauri.conf.json5"); - let config: Config = serde_json::from_str(&read_to_string("tauri.conf.json")?)?; + let config: Config = if let Ok(env) = std::env::var("TAURI_CONFIG") { + serde_json::from_str(&env)? + } else { + serde_json::from_value(tauri_utils::config::parse::read_from( + std::env::current_dir().unwrap(), + )?)? + }; let mut manifest = Manifest::from_path("Cargo.toml")?; if let Some(tauri) = manifest.dependencies.remove("tauri") { diff --git a/core/tauri-codegen/Cargo.toml b/core/tauri-codegen/Cargo.toml index 48a5e62ce..057892b89 100644 --- a/core/tauri-codegen/Cargo.toml +++ b/core/tauri-codegen/Cargo.toml @@ -32,3 +32,4 @@ default = [ "compression" ] compression = [ "zstd", "tauri-utils/compression" ] isolation = ["tauri-utils/isolation"] shell-scope = [] +config-json5 = [ "tauri-utils/config-json5" ] diff --git a/core/tauri-codegen/src/lib.rs b/core/tauri-codegen/src/lib.rs index 4a2f08e8c..4eedaa143 100644 --- a/core/tauri-codegen/src/lib.rs +++ b/core/tauri-codegen/src/lib.rs @@ -2,23 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -pub use context::{context_codegen, ContextData}; +pub use self::context::{context_codegen, ContextData}; use std::{ borrow::Cow, - fs::File, - io::BufReader, path::{Path, PathBuf}, }; -use tauri_utils::config::Config; -use thiserror::Error; +pub use tauri_utils::config::{parse::ConfigError, Config}; mod context; pub mod embedded_assets; -/// Represents all the errors that can happen while reading the config. -#[derive(Debug, Error)] +/// Represents all the errors that can happen while reading the config during codegen. +#[derive(Debug, thiserror::Error)] #[non_exhaustive] -pub enum ConfigError { +pub enum CodegenConfigError { #[error("unable to access current working directory: {0}")] CurrentDir(std::io::Error), @@ -26,29 +23,20 @@ pub enum ConfigError { #[error("Tauri config file has no parent, this shouldn't be possible. file an issue on https://github.com/tauri-apps/tauri - target {0}")] Parent(PathBuf), - #[error("unable to parse inline TAURI_CONFIG env var: {0}")] + #[error("unable to parse inline JSON TAURI_CONFIG env var: {0}")] FormatInline(serde_json::Error), - #[error("unable to parse Tauri config file at {path} because {error}")] - Format { - path: PathBuf, - error: serde_json::Error, - }, - - #[error("unable to read Tauri config file at {path} because {error}")] - Io { - path: PathBuf, - error: std::io::Error, - }, + #[error("{0}")] + ConfigError(#[from] ConfigError), } /// Get the [`Config`] from the `TAURI_CONFIG` environmental variable, or read from the passed path. /// /// If the passed path is relative, it should be relative to the current working directory of the /// compiling crate. -pub fn get_config(path: &Path) -> Result<(Config, PathBuf), ConfigError> { +pub fn get_config(path: &Path) -> Result<(Config, PathBuf), CodegenConfigError> { let path = if path.is_relative() { - let cwd = std::env::current_dir().map_err(ConfigError::CurrentDir)?; + let cwd = std::env::current_dir().map_err(CodegenConfigError::CurrentDir)?; Cow::Owned(cwd.join(path)) } else { Cow::Borrowed(path) @@ -59,27 +47,16 @@ pub fn get_config(path: &Path) -> Result<(Config, PathBuf), ConfigError> { // already unlikely unless the developer goes out of their way to run the cli on a different // project than the target crate. let config = if let Ok(env) = std::env::var("TAURI_CONFIG") { - serde_json::from_str(&env).map_err(ConfigError::FormatInline)? + serde_json::from_str(&env).map_err(CodegenConfigError::FormatInline)? } else { - File::open(&path) - .map_err(|error| ConfigError::Io { - path: path.clone().into_owned(), - error, - }) - .map(BufReader::new) - .and_then(|file| { - serde_json::from_reader(file).map_err(|error| ConfigError::Format { - path: path.clone().into_owned(), - error, - }) - })? + tauri_utils::config::parse(path.to_path_buf())? }; // this should be impossible because of the use of `current_dir()` above, but handle it anyways let parent = path .parent() .map(ToOwned::to_owned) - .ok_or_else(|| ConfigError::Parent(path.into_owned()))?; + .ok_or_else(|| CodegenConfigError::Parent(path.into_owned()))?; Ok((config, parent)) } diff --git a/core/tauri-macros/Cargo.toml b/core/tauri-macros/Cargo.toml index 4f9fb3e5a..ea8c40506 100644 --- a/core/tauri-macros/Cargo.toml +++ b/core/tauri-macros/Cargo.toml @@ -21,9 +21,11 @@ quote = "1" syn = { version = "1", features = [ "full" ] } heck = "0.3" tauri-codegen = { version = "1.0.0-beta.4", default-features = false, path = "../tauri-codegen" } +tauri-utils = { version = "1.0.0-beta.3", path = "../tauri-utils" } [features] custom-protocol = [ ] compression = [ "tauri-codegen/compression" ] isolation = ["tauri-codegen/isolation"] shell-scope = ["tauri-codegen/shell-scope"] +config-json5 = [ "tauri-codegen/config-json5", "tauri-utils/config-json5" ] diff --git a/core/tauri-macros/src/context.rs b/core/tauri-macros/src/context.rs index 76f5a066d..337fe3a60 100644 --- a/core/tauri-macros/src/context.rs +++ b/core/tauri-macros/src/context.rs @@ -11,6 +11,7 @@ use syn::{ LitStr, PathArguments, PathSegment, Token, }; use tauri_codegen::{context_codegen, get_config, ContextData}; +use tauri_utils::config::parse::does_supported_extension_exist; pub(crate) struct ContextItems { config_file: PathBuf, @@ -35,7 +36,7 @@ impl Parse for ContextItems { VarError::NotUnicode(_) => "CARGO_MANIFEST_DIR env var contained invalid utf8".into(), }) .and_then(|path| { - if path.exists() { + if does_supported_extension_exist(&path) { Ok(path) } else { Err(format!( diff --git a/core/tauri-utils/Cargo.toml b/core/tauri-utils/Cargo.toml index 2303d4429..561b8edcb 100644 --- a/core/tauri-utils/Cargo.toml +++ b/core/tauri-utils/Cargo.toml @@ -31,6 +31,8 @@ ring = { version = "0.16", optional = true, features = ["std"] } once_cell = { version = "1.8", optional = true } serialize-to-javascript = { git = "https://github.com/chippers/serialize-to-javascript" } ctor = "0.1" +json5 = { version = "0.4", optional = true } +json-patch = "0.2" [target."cfg(target_os = \"linux\")".dependencies] heck = "0.4" @@ -41,3 +43,4 @@ compression = [ "zstd" ] schema = ["schemars"] isolation = [ "aes-gcm", "ring", "once_cell" ] process-relaunch-dangerous-allow-symlink-macos = [] +config-json5 = [ "json5" ] diff --git a/core/tauri-utils/src/config.rs b/core/tauri-utils/src/config.rs index 416f4fd4b..88ed40c55 100644 --- a/core/tauri-utils/src/config.rs +++ b/core/tauri-utils/src/config.rs @@ -24,6 +24,11 @@ use url::Url; use std::{collections::HashMap, fmt, fs::read_to_string, path::PathBuf}; +/// Items to help with parsing content into a [`Config`]. +pub mod parse; + +pub use self::parse::parse; + /// The window webview URL options. #[derive(PartialEq, Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(JsonSchema))] diff --git a/core/tauri-utils/src/config/parse.rs b/core/tauri-utils/src/config/parse.rs new file mode 100644 index 000000000..6fa49f7fa --- /dev/null +++ b/core/tauri-utils/src/config/parse.rs @@ -0,0 +1,236 @@ +// Copyright 2019-2021 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use crate::config::Config; +use json_patch::merge; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// All extensions that are possibly supported, but perhaps not enabled. +pub const EXTENSIONS_SUPPORTED: &[&str] = &["json", "json5"]; + +/// All extensions that are currently enabled. +pub const EXTENSIONS_ENABLED: &[&str] = &[ + "json", + #[cfg(feature = "config-json5")] + "json5", +]; + +/// Represents all the errors that can happen while reading the config. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ConfigError { + /// Failed to parse from JSON. + #[error("unable to parse JSON Tauri config file at {path} because {error}")] + FormatJson { + /// The path that failed to parse into JSON. + path: PathBuf, + + /// The parsing [`serde_json::Error`]. + error: serde_json::Error, + }, + + /// Failed to parse from JSON5. + #[cfg(feature = "config-json5")] + #[error("unable to parse JSON5 Tauri config file at {path} because {error}")] + FormatJson5 { + /// The path that failed to parse into JSON5. + path: PathBuf, + + /// The parsing [`json5::Error`]. + error: ::json5::Error, + }, + + /// Unknown file extension encountered. + #[error("unsupported format encountered {0}")] + UnsupportedFormat(String), + + /// Known file extension encountered, but corresponding parser is not enabled (cargo features). + #[error("supported (but disabled) format encountered {extension} - try enabling `{feature}` ")] + DisabledFormat { + /// The extension encountered. + extension: String, + + /// The cargo feature to enable it. + feature: String, + }, + + /// A generic IO error with context of what caused it. + #[error("unable to read Tauri config file at {path} because {error}")] + Io { + /// The path the IO error occured on. + path: PathBuf, + + /// The [`std::io::Error`]. + error: std::io::Error, + }, +} + +/// Reads the configuration from the given root directory. +/// +/// It first looks for a `tauri.conf.json[5]` file on the given directory. The file must exist. +/// Then it looks for a platform-specific configuration file: +/// - `tauri.macos.conf.json[5]` on macOS +/// - `tauri.linux.conf.json[5]` on Linux +/// - `tauri.windows.conf.json[5]` on Windows +/// Merging the configurations using [JSON Merge Patch (RFC 7396)]. +/// +/// [JSON Merge Patch (RFC 7396)]: https://datatracker.ietf.org/doc/html/rfc7396. +pub fn read_from(root_dir: PathBuf) -> Result { + let mut config: Value = parse_value(root_dir.join("tauri.conf.json"))?; + + let platform_config_filename = if cfg!(target_os = "macos") { + "tauri.macos.conf.json" + } else if cfg!(windows) { + "tauri.windows.conf.json" + } else { + "tauri.linux.conf.json" + }; + let platform_config_path = root_dir.join(platform_config_filename); + if does_supported_extension_exist(&platform_config_path) { + let platform_config: Value = parse_value(platform_config_path)?; + merge(&mut config, &platform_config); + } + Ok(config) +} + +/// Check if a supported config file exists at path. +/// +/// The passed path is expected to be the path to the "default" configuration format, in this case +/// JSON with `.json`. +pub fn does_supported_extension_exist(path: impl Into) -> bool { + let path = path.into(); + EXTENSIONS_ENABLED + .iter() + .any(|ext| path.with_extension(ext).exists()) +} + +/// Parse the config from path, including alternative formats. +/// +/// Hierarchy: +/// 1. Check if `tauri.conf.json` exists +/// a. Parse it with `serde_json` +/// b. Parse it with `json5` if `serde_json` fails +/// c. Return original `serde_json` error if all above steps failed +/// 2. Check if `tauri.conf.json5` exists +/// a. Parse it with `json5` +/// b. Return error if all above steps failed +/// 3. Return error if all above steps failed +pub fn parse(path: impl Into) -> Result { + do_parse(path.into()) +} + +/// See [`parse`] for specifics, returns a JSON [`Value`] instead of [`Config`]. +pub fn parse_value(path: impl Into) -> Result { + do_parse(path.into()) +} + +fn do_parse(path: PathBuf) -> Result { + let json5 = path.with_extension("json5"); + let path_ext = path + .extension() + .map(OsStr::to_string_lossy) + .unwrap_or_default(); + + if path.exists() { + let raw = read_to_string(&path)?; + + // to allow us to easily use the compile-time #[cfg], we always bind + #[allow(clippy::let_and_return)] + let json = do_parse_json(&raw, &path); + + // we also want to support **valid** json5 in the .json extension if the feature is enabled. + // if the json5 is not valid the serde_json error for regular json will be returned. + // this could be a bit confusing, so we may want to encourage users using json5 to use the + // .json5 extension instead of .json + #[cfg(feature = "config-json5")] + let json = { + match do_parse_json5(&raw, &path) { + json5 @ Ok(_) => json5, + + // assume any errors from json5 in a .json file is because it's not json5 + Err(_) => json, + } + }; + + json + } else if json5.exists() { + #[cfg(feature = "config-json5")] + { + let raw = read_to_string(&json5)?; + do_parse_json5(&raw, &path) + } + + #[cfg(not(feature = "config-json5"))] + Err(ConfigError::DisabledFormat { + extension: ".json5".into(), + feature: "config-json5".into(), + }) + } else if !EXTENSIONS_SUPPORTED.contains(&path_ext.as_ref()) { + Err(ConfigError::UnsupportedFormat(path_ext.to_string())) + } else { + Err(ConfigError::Io { + path, + error: std::io::ErrorKind::NotFound.into(), + }) + } +} + +/// "Low-level" helper to parse JSON into a [`Config`]. +/// +/// `raw` should be the contents of the file that is represented by `path`. +pub fn parse_json(raw: &str, path: &Path) -> Result { + do_parse_json(raw, path) +} + +/// "Low-level" helper to parse JSON into a JSON [`Value`]. +/// +/// `raw` should be the contents of the file that is represented by `path`. +pub fn parse_json_value(raw: &str, path: &Path) -> Result { + do_parse_json(raw, path) +} + +fn do_parse_json(raw: &str, path: &Path) -> Result { + serde_json::from_str(raw).map_err(|error| ConfigError::FormatJson { + path: path.into(), + error, + }) +} + +/// "Low-level" helper to parse JSON5 into a [`Config`]. +/// +/// `raw` should be the contents of the file that is represented by `path`. This function requires +/// the `config-json5` feature to be enabled. +#[cfg(feature = "config-json5")] +pub fn parse_json5(raw: &str, path: &Path) -> Result { + do_parse_json5(raw, path) +} + +/// "Low-level" helper to parse JSON5 into a JSON [`Value`]. +/// +/// `raw` should be the contents of the file that is represented by `path`. This function requires +/// the `config-json5` feature to be enabled. +#[cfg(feature = "config-json5")] +pub fn parse_json5_value(raw: &str, path: &Path) -> Result { + do_parse_json5(raw, path) +} + +#[cfg(feature = "config-json5")] +fn do_parse_json5(raw: &str, path: &Path) -> Result { + ::json5::from_str(raw).map_err(|error| ConfigError::FormatJson5 { + path: path.into(), + error, + }) +} + +/// Helper function to wrap IO errors from [`std::fs::read_to_string`] into a [`ConfigError`]. +fn read_to_string(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|error| ConfigError::Io { + path: path.into(), + error, + }) +} diff --git a/core/tauri/Cargo.toml b/core/tauri/Cargo.toml index 1f4b67c65..091cdb819 100644 --- a/core/tauri/Cargo.toml +++ b/core/tauri/Cargo.toml @@ -232,6 +232,9 @@ window-start-dragging = [ ] window-print = [ ] egui = ["epi", "tauri-runtime-wry/egui"] +# features unrelated to api/endpoints +config-json5 = [ "tauri-macros/config-json5" ] + [[example]] name = "commands" path = "../../examples/commands/src-tauri/src/main.rs" diff --git a/tooling/cli.rs/Cargo.lock b/tooling/cli.rs/Cargo.lock index 1387272c5..27e670a7e 100644 --- a/tooling/cli.rs/Cargo.lock +++ b/tooling/cli.rs/Cargo.lock @@ -1071,6 +1071,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonway" version = "2.0.0" @@ -2467,6 +2478,8 @@ dependencies = [ "ctor", "heck", "html5ever", + "json-patch", + "json5", "kuchiki", "once_cell", "phf 0.10.1", diff --git a/tooling/cli.rs/Cargo.toml b/tooling/cli.rs/Cargo.toml index e44bde9b5..ad0b50e80 100644 --- a/tooling/cli.rs/Cargo.toml +++ b/tooling/cli.rs/Cargo.toml @@ -30,8 +30,7 @@ notify = "4.0" shared_child = "1.0" toml_edit = "0.12" json-patch = "0.2" -tauri-utils = { version = "1.0.0-beta.3", path = "../../core/tauri-utils", features = ["isolation", "schema"] } -schemars = { version = "0.8", features = ["url"] } +tauri-utils = { version = "1.0.0-beta.3", path = "../../core/tauri-utils", features = ["isolation", "schema", "config-json5"] } toml = "0.5" valico = "3.6" handlebars = "4.2" diff --git a/tooling/cli.rs/src/helpers/config.rs b/tooling/cli.rs/src/helpers/config.rs index 18f13c116..20099b4b8 100644 --- a/tooling/cli.rs/src/helpers/config.rs +++ b/tooling/cli.rs/src/helpers/config.rs @@ -29,8 +29,6 @@ pub fn wix_settings(config: WixConfig) -> tauri_bundler::WixSettings { use std::{ env::set_var, - fs::File, - io::BufReader, process::exit, sync::{Arc, Mutex}, }; @@ -48,11 +46,13 @@ fn get_internal(merge_config: Option<&str>, reload: bool) -> crate::Result, reload: bool) -> crate::Result