use std::fs;
use camino::{Utf8Path, Utf8PathBuf};
use schemars::{JsonSchema, schema_for};
use semver::Version;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{CONFIG_SCHEMA_VERSION, validate_application_identifier};
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FerryConfig {
pub schema_version: u32,
#[serde(default = "default_platforms")]
pub platforms: Vec<TargetPlatform>,
pub app: AppConfig,
#[serde(default)]
pub android: AndroidConfig,
#[serde(default)]
pub ios: IosConfig,
#[serde(default)]
pub capabilities: CapabilitiesConfig,
#[serde(default)]
pub permissions: PermissionsConfig,
#[serde(default)]
pub extensions: ExtensionsConfig,
}
impl FerryConfig {
pub fn starter(name: impl Into<String>, identifier: impl Into<String>) -> Self {
let identifier = identifier.into();
let default_scheme = identifier
.rsplit('.')
.next()
.unwrap_or("app")
.replace('_', "-");
let mut capabilities = CapabilitiesConfig::starter();
capabilities.deep_links.schemes.push(default_scheme);
Self {
schema_version: CONFIG_SCHEMA_VERSION,
platforms: default_platforms(),
app: AppConfig {
name: name.into(),
identifier,
version: Version::new(0, 1, 0),
display_version: "0.1.0".to_owned(),
window: AppWindowConfig::default(),
},
android: AndroidConfig::default(),
ios: IosConfig::default(),
capabilities,
permissions: PermissionsConfig::default(),
extensions: ExtensionsConfig::default(),
}
}
pub fn parse(source: &str) -> Result<Self, ConfigError> {
toml::from_str(source).map_err(ConfigError::Parse)
}
pub fn load(path: &Utf8Path) -> Result<Self, ConfigError> {
let source = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_owned(),
source,
})?;
let config = Self::parse(&source)?;
config.validate_or_error()?;
Ok(config)
}
pub fn to_pretty_toml(&self) -> Result<String, ConfigError> {
toml::to_string_pretty(self).map_err(ConfigError::Serialize)
}
pub fn json_schema() -> Result<String, ConfigError> {
serde_json::to_string_pretty(&schema_for!(Self)).map_err(ConfigError::Schema)
}
#[allow(clippy::too_many_lines)]
pub fn validate(&self) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if self.schema_version != CONFIG_SCHEMA_VERSION {
issues.push(ValidationIssue::new(
"schema_version",
format!(
"schema version {} is not supported; this cargo-ferry supports version {CONFIG_SCHEMA_VERSION}",
self.schema_version
),
"Run `cargo ferry config migrate` with a cargo-ferry version that supports the source schema.",
));
}
if self.app.name.trim().is_empty() {
issues.push(ValidationIssue::new(
"app.name",
"the display name cannot be empty",
"Set a human-readable application name.",
));
}
if self.platforms.is_empty() {
issues.push(ValidationIssue::new(
"platforms",
"at least one target platform is required",
"Use `platforms = [\"android\"]` or `platforms = [\"android\", \"ios\"]`.",
));
}
let unique_platforms = self
.platforms
.iter()
.collect::<std::collections::HashSet<_>>();
if unique_platforms.len() != self.platforms.len() {
issues.push(ValidationIssue::new(
"platforms",
"the same target platform is listed more than once",
"Remove the duplicate platform entry.",
));
}
if let Err(error) = validate_application_identifier(&self.app.identifier) {
issues.push(ValidationIssue::new(
"app.identifier",
error.to_string(),
"Use an identifier such as `com.example.weather`.",
));
}
if self.app.display_version.trim().is_empty() {
issues.push(ValidationIssue::new(
"app.display_version",
"the display version cannot be empty",
"Use a value visible to users, commonly the same value as `app.version`.",
));
}
if !(21..=100).contains(&self.android.min_sdk) {
issues.push(ValidationIssue::new(
"android.min_sdk",
"the supported range is 21 through 100",
"Use 26 unless the application needs an older Android release.",
));
}
if self.android.abis.is_empty() {
issues.push(ValidationIssue::new(
"android.abis",
"at least one Android ABI is required",
"Add `arm64-v8a` for physical devices or `x86_64` for an Intel emulator.",
));
}
if self
.android
.abis
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
!= self.android.abis.len()
{
issues.push(ValidationIssue::new(
"android.abis",
"the same Android ABI is listed more than once",
"Remove duplicate ABI entries.",
));
}
if self.android.target_sdk != "installed" {
match self.android.target_sdk.parse::<u32>() {
Ok(target_sdk) if target_sdk < self.android.min_sdk => {
issues.push(ValidationIssue::new(
"android.target_sdk",
"the target SDK cannot be lower than the minimum SDK",
"Raise `android.target_sdk` or lower `android.min_sdk`.",
));
}
Ok(target_sdk) if target_sdk > 100 => {
issues.push(ValidationIssue::new(
"android.target_sdk",
"the numeric target SDK is outside the supported range",
"Use `installed` or a supported Android API number.",
));
}
Ok(_) => {}
Err(_) => issues.push(ValidationIssue::new(
"android.target_sdk",
"the target SDK must be `installed` or a numeric API level",
"Use `target_sdk = \"installed\"` to select the newest compatible installed platform.",
)),
}
}
if parse_platform_version(&self.ios.min_version).is_none() {
issues.push(ValidationIssue::new(
"ios.min_version",
"the minimum iOS version must contain numeric major and minor components",
"Use a value such as `16.0`.",
));
}
if self.capabilities.network.mode == NetworkMode::None
&& self.capabilities.network.probe_url.is_some()
{
issues.push(ValidationIssue::new(
"capabilities.network.probe_url",
"a probe URL cannot be configured when network mode is `none`",
"Remove `probe_url` or choose the `status`, `optional`, or `required` mode.",
));
}
if self.capabilities.network.probe_timeout_ms == 0 {
issues.push(ValidationIssue::new(
"capabilities.network.probe_timeout_ms",
"the probe timeout must be greater than zero",
"Use 3000 milliseconds as a practical starting point.",
));
}
if self.capabilities.network.probe_timeout_ms > 120_000 {
issues.push(ValidationIssue::new(
"capabilities.network.probe_timeout_ms",
"the probe timeout cannot exceed 120000 milliseconds",
"Choose a bounded timeout appropriate for a user-facing operation.",
));
}
if let Some(probe_url) = &self.capabilities.network.probe_url {
match url::Url::parse(probe_url) {
Ok(url) if matches!(url.scheme(), "http" | "https") => {}
Ok(_) => issues.push(ValidationIssue::new(
"capabilities.network.probe_url",
"only HTTP and HTTPS probe endpoints are supported",
"Use an HTTPS health endpoint controlled by the application backend.",
)),
Err(error) => issues.push(ValidationIssue::new(
"capabilities.network.probe_url",
format!("the probe endpoint is not an absolute URL: {error}"),
"Use an absolute HTTPS URL, or omit it and pass an endpoint explicitly.",
)),
}
}
if self.capabilities.notifications.push {
issues.push(ValidationIssue::new(
"capabilities.notifications.push",
"remote push is not implemented in schema version 1",
"Set `push = false`; local notifications remain available.",
));
}
for scheme in &self.capabilities.deep_links.schemes {
if !valid_url_scheme(scheme) {
issues.push(ValidationIssue::new(
"capabilities.deep_links.schemes",
format!("`{scheme}` is not a safe custom URL scheme"),
"Schemes must start with an ASCII letter and contain only letters, digits, `+`, `-`, or `.`.",
));
}
}
if self.extensions.widget.enabled && self.extensions.widget.app_group.is_none() {
issues.push(ValidationIssue::new(
"extensions.widget.app_group",
"the widget extension needs an application group for shared state",
format!("Set `app_group = \"group.{}\"`.", self.app.identifier),
));
}
if self.extensions.live_activity.enabled
&& parse_platform_version(&self.ios.min_version)
.is_some_and(|version| version < (16, 1))
{
issues.push(ValidationIssue::new(
"ios.min_version",
"Live Activities require iOS 16.1 or newer",
"Set `ios.min_version = \"16.1\"` or disable the Live Activity extension.",
));
}
validate_permission(
"permissions.camera",
&self.permissions.camera,
"Explain why the app needs camera access.",
&mut issues,
);
validate_permission(
"permissions.photos",
&self.permissions.photos,
"Explain why the app needs photo-library access.",
&mut issues,
);
validate_permission(
"permissions.microphone",
&self.permissions.microphone,
"Explain why the app needs microphone access.",
&mut issues,
);
validate_permission(
"permissions.location_when_in_use",
&self.permissions.location_when_in_use,
"Explain why the app needs location while in use.",
&mut issues,
);
validate_permission(
"permissions.local_network",
&self.permissions.local_network,
"Explain which local devices or services the app connects to.",
&mut issues,
);
issues
}
pub fn validate_or_error(&self) -> Result<(), ConfigError> {
let issues = self.validate();
if issues.is_empty() {
Ok(())
} else {
Err(ConfigError::Validation { issues })
}
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PermissionsConfig {
pub camera: PermissionConfig,
pub photos: PermissionConfig,
pub microphone: PermissionConfig,
pub location_when_in_use: PermissionConfig,
pub local_network: PermissionConfig,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PermissionConfig {
pub enabled: bool,
pub purpose: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TargetPlatform {
Android,
Ios,
}
fn default_platforms() -> Vec<TargetPlatform> {
vec![TargetPlatform::Android, TargetPlatform::Ios]
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
pub name: String,
pub identifier: String,
pub version: Version,
pub display_version: String,
#[serde(default)]
pub window: AppWindowConfig,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AppWindowConfig {
#[serde(default)]
pub orientation: Orientation,
#[serde(default)]
pub theme: Theme,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Orientation {
#[default]
Automatic,
Portrait,
Landscape,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Theme {
#[default]
System,
Light,
Dark,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AndroidConfig {
pub min_sdk: u32,
pub target_sdk: String,
pub abis: Vec<AndroidAbi>,
}
impl Default for AndroidConfig {
fn default() -> Self {
Self {
min_sdk: 26,
target_sdk: "installed".to_owned(),
abis: vec![AndroidAbi::Arm64V8a],
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Eq, Hash, PartialEq, Serialize)]
pub enum AndroidAbi {
#[serde(rename = "arm64-v8a")]
Arm64V8a,
#[serde(rename = "x86_64")]
X86_64,
#[serde(rename = "armeabi-v7a")]
ArmeabiV7a,
}
impl AndroidAbi {
pub const fn rust_target(self) -> &'static str {
match self {
Self::Arm64V8a => "aarch64-linux-android",
Self::X86_64 => "x86_64-linux-android",
Self::ArmeabiV7a => "armv7-linux-androideabi",
}
}
pub const fn apk_directory(self) -> &'static str {
match self {
Self::Arm64V8a => "arm64-v8a",
Self::X86_64 => "x86_64",
Self::ArmeabiV7a => "armeabi-v7a",
}
}
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct IosConfig {
pub min_version: String,
}
impl Default for IosConfig {
fn default() -> Self {
Self {
min_version: "16.0".to_owned(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CapabilitiesConfig {
pub network: NetworkCapability,
pub notifications: NotificationCapability,
pub storage: BoolCapability,
pub haptics: BoolCapability,
pub clipboard: BoolCapability,
pub share: BoolCapability,
pub deep_links: DeepLinksCapability,
}
impl CapabilitiesConfig {
fn starter() -> Self {
Self {
network: NetworkCapability {
mode: NetworkMode::Status,
..NetworkCapability::default()
},
notifications: NotificationCapability {
local: true,
push: false,
},
storage: BoolCapability { enabled: true },
haptics: BoolCapability { enabled: true },
clipboard: BoolCapability::default(),
share: BoolCapability::default(),
deep_links: DeepLinksCapability::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoolCapability {
pub enabled: bool,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkCapability {
pub mode: NetworkMode,
pub probe_url: Option<String>,
pub probe_timeout_ms: u64,
}
impl Default for NetworkCapability {
fn default() -> Self {
Self {
mode: NetworkMode::None,
probe_url: None,
probe_timeout_ms: 3_000,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkMode {
#[default]
None,
Status,
Optional,
Required,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct NotificationCapability {
pub local: bool,
pub push: bool,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DeepLinksCapability {
pub schemes: Vec<String>,
pub allowed_hosts: Vec<String>,
pub allowed_actions: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ExtensionsConfig {
pub widget: WidgetConfig,
pub live_activity: LiveActivityConfig,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct WidgetConfig {
pub enabled: bool,
pub app_group: Option<String>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct LiveActivityConfig {
pub enabled: bool,
pub android_fallback: AndroidLiveActivityFallback,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum AndroidLiveActivityFallback {
None,
#[default]
OngoingNotification,
}
impl Default for LiveActivityConfig {
fn default() -> Self {
Self {
enabled: false,
android_fallback: AndroidLiveActivityFallback::OngoingNotification,
}
}
}
#[derive(Clone, Debug, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
pub struct ValidationIssue {
pub field: String,
pub message: String,
pub help: String,
}
impl ValidationIssue {
fn new(field: impl Into<String>, message: impl Into<String>, help: impl Into<String>) -> Self {
Self {
field: field.into(),
message: message.into(),
help: help.into(),
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("could not read configuration at {path}: {source}")]
Read {
path: Utf8PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid ferry.toml: {0}")]
Parse(#[source] toml::de::Error),
#[error("ferry.toml has invalid values")]
Validation {
issues: Vec<ValidationIssue>,
},
#[error("could not serialize ferry.toml: {0}")]
Serialize(#[source] toml::ser::Error),
#[error("could not serialize ferry.toml JSON Schema: {0}")]
Schema(#[source] serde_json::Error),
}
fn parse_platform_version(value: &str) -> Option<(u32, u32)> {
let mut parts = value.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor))
}
fn valid_url_scheme(scheme: &str) -> bool {
let mut characters = scheme.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& characters.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
})
}
fn validate_permission(
field: &'static str,
permission: &PermissionConfig,
help: &'static str,
issues: &mut Vec<ValidationIssue>,
) {
if permission.enabled
&& permission
.purpose
.as_deref()
.is_none_or(|purpose| purpose.trim().is_empty())
{
issues.push(ValidationIssue::new(
format!("{field}.purpose"),
"an enabled permission requires a non-empty purpose string",
help,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starter_configuration_round_trips() {
let config = FerryConfig::starter("Weather", "com.example.weather");
let encoded = config.to_pretty_toml().unwrap();
let decoded = FerryConfig::parse(&encoded).unwrap();
assert_eq!(decoded, config);
assert!(decoded.validate().is_empty());
}
#[test]
fn unknown_fields_are_rejected() {
let source = FerryConfig::starter("Weather", "com.example.weather")
.to_pretty_toml()
.unwrap()
+ "\nunknown = true\n";
assert!(FerryConfig::parse(&source).is_err());
}
#[test]
fn reports_capability_conflicts_together() {
let mut config = FerryConfig::starter("Weather", "com.example.weather");
config.capabilities.network.mode = NetworkMode::None;
config.capabilities.network.probe_url = Some("https://example.invalid/health".to_owned());
config.capabilities.notifications.push = true;
assert_eq!(config.validate().len(), 2);
}
#[test]
fn schema_is_valid_json() {
let schema = FerryConfig::json_schema().unwrap();
let value: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert_eq!(value["title"], "FerryConfig");
}
#[test]
fn enabled_permissions_require_purpose_text() {
let mut config = FerryConfig::starter("Weather", "com.example.weather");
config.permissions.camera.enabled = true;
assert!(
config
.validate()
.iter()
.any(|issue| issue.field == "permissions.camera.purpose")
);
config.permissions.camera.purpose = Some("Scan a profile photo".to_owned());
assert!(config.validate().is_empty());
}
}