1use std::fs;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use schemars::{JsonSchema, schema_for};
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::{CONFIG_SCHEMA_VERSION, validate_application_identifier};
10
11#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct FerryConfig {
15 pub schema_version: u32,
17 #[serde(default = "default_platforms")]
19 pub platforms: Vec<TargetPlatform>,
20 pub app: AppConfig,
22 #[serde(default)]
24 pub android: AndroidConfig,
25 #[serde(default)]
27 pub ios: IosConfig,
28 #[serde(default)]
30 pub capabilities: CapabilitiesConfig,
31 #[serde(default)]
33 pub permissions: PermissionsConfig,
34 #[serde(default)]
36 pub extensions: ExtensionsConfig,
37}
38
39impl FerryConfig {
40 pub fn starter(name: impl Into<String>, identifier: impl Into<String>) -> Self {
42 let identifier = identifier.into();
43 let default_scheme = identifier
44 .rsplit('.')
45 .next()
46 .unwrap_or("app")
47 .replace('_', "-");
48 let mut capabilities = CapabilitiesConfig::starter();
49 capabilities.deep_links.schemes.push(default_scheme);
50 Self {
51 schema_version: CONFIG_SCHEMA_VERSION,
52 platforms: default_platforms(),
53 app: AppConfig {
54 name: name.into(),
55 identifier,
56 version: Version::new(0, 1, 0),
57 display_version: "0.1.0".to_owned(),
58 window: AppWindowConfig::default(),
59 },
60 android: AndroidConfig::default(),
61 ios: IosConfig::default(),
62 capabilities,
63 permissions: PermissionsConfig::default(),
64 extensions: ExtensionsConfig::default(),
65 }
66 }
67
68 pub fn parse(source: &str) -> Result<Self, ConfigError> {
74 toml::from_str(source).map_err(ConfigError::Parse)
75 }
76
77 pub fn load(path: &Utf8Path) -> Result<Self, ConfigError> {
83 let source = fs::read_to_string(path).map_err(|source| ConfigError::Read {
84 path: path.to_owned(),
85 source,
86 })?;
87 let config = Self::parse(&source)?;
88 config.validate_or_error()?;
89 Ok(config)
90 }
91
92 pub fn to_pretty_toml(&self) -> Result<String, ConfigError> {
98 toml::to_string_pretty(self).map_err(ConfigError::Serialize)
99 }
100
101 pub fn json_schema() -> Result<String, ConfigError> {
107 serde_json::to_string_pretty(&schema_for!(Self)).map_err(ConfigError::Schema)
108 }
109
110 #[allow(clippy::too_many_lines)]
112 pub fn validate(&self) -> Vec<ValidationIssue> {
113 let mut issues = Vec::new();
114 if self.schema_version != CONFIG_SCHEMA_VERSION {
115 issues.push(ValidationIssue::new(
116 "schema_version",
117 format!(
118 "schema version {} is not supported; this cargo-ferry supports version {CONFIG_SCHEMA_VERSION}",
119 self.schema_version
120 ),
121 "Run `cargo ferry config migrate` with a cargo-ferry version that supports the source schema.",
122 ));
123 }
124 if self.app.name.trim().is_empty() {
125 issues.push(ValidationIssue::new(
126 "app.name",
127 "the display name cannot be empty",
128 "Set a human-readable application name.",
129 ));
130 }
131 if self.platforms.is_empty() {
132 issues.push(ValidationIssue::new(
133 "platforms",
134 "at least one target platform is required",
135 "Use `platforms = [\"android\"]` or `platforms = [\"android\", \"ios\"]`.",
136 ));
137 }
138 let unique_platforms = self
139 .platforms
140 .iter()
141 .collect::<std::collections::HashSet<_>>();
142 if unique_platforms.len() != self.platforms.len() {
143 issues.push(ValidationIssue::new(
144 "platforms",
145 "the same target platform is listed more than once",
146 "Remove the duplicate platform entry.",
147 ));
148 }
149 if let Err(error) = validate_application_identifier(&self.app.identifier) {
150 issues.push(ValidationIssue::new(
151 "app.identifier",
152 error.to_string(),
153 "Use an identifier such as `com.example.weather`.",
154 ));
155 }
156 if self.app.display_version.trim().is_empty() {
157 issues.push(ValidationIssue::new(
158 "app.display_version",
159 "the display version cannot be empty",
160 "Use a value visible to users, commonly the same value as `app.version`.",
161 ));
162 }
163 if !(21..=100).contains(&self.android.min_sdk) {
164 issues.push(ValidationIssue::new(
165 "android.min_sdk",
166 "the supported range is 21 through 100",
167 "Use 26 unless the application needs an older Android release.",
168 ));
169 }
170 if self.android.abis.is_empty() {
171 issues.push(ValidationIssue::new(
172 "android.abis",
173 "at least one Android ABI is required",
174 "Add `arm64-v8a` for physical devices or `x86_64` for an Intel emulator.",
175 ));
176 }
177 if self
178 .android
179 .abis
180 .iter()
181 .collect::<std::collections::HashSet<_>>()
182 .len()
183 != self.android.abis.len()
184 {
185 issues.push(ValidationIssue::new(
186 "android.abis",
187 "the same Android ABI is listed more than once",
188 "Remove duplicate ABI entries.",
189 ));
190 }
191 if self.android.target_sdk != "installed" {
192 match self.android.target_sdk.parse::<u32>() {
193 Ok(target_sdk) if target_sdk < self.android.min_sdk => {
194 issues.push(ValidationIssue::new(
195 "android.target_sdk",
196 "the target SDK cannot be lower than the minimum SDK",
197 "Raise `android.target_sdk` or lower `android.min_sdk`.",
198 ));
199 }
200 Ok(target_sdk) if target_sdk > 100 => {
201 issues.push(ValidationIssue::new(
202 "android.target_sdk",
203 "the numeric target SDK is outside the supported range",
204 "Use `installed` or a supported Android API number.",
205 ));
206 }
207 Ok(_) => {}
208 Err(_) => issues.push(ValidationIssue::new(
209 "android.target_sdk",
210 "the target SDK must be `installed` or a numeric API level",
211 "Use `target_sdk = \"installed\"` to select the newest compatible installed platform.",
212 )),
213 }
214 }
215 if parse_platform_version(&self.ios.min_version).is_none() {
216 issues.push(ValidationIssue::new(
217 "ios.min_version",
218 "the minimum iOS version must contain numeric major and minor components",
219 "Use a value such as `16.0`.",
220 ));
221 }
222 if self.capabilities.network.mode == NetworkMode::None
223 && self.capabilities.network.probe_url.is_some()
224 {
225 issues.push(ValidationIssue::new(
226 "capabilities.network.probe_url",
227 "a probe URL cannot be configured when network mode is `none`",
228 "Remove `probe_url` or choose the `status`, `optional`, or `required` mode.",
229 ));
230 }
231 if self.capabilities.network.probe_timeout_ms == 0 {
232 issues.push(ValidationIssue::new(
233 "capabilities.network.probe_timeout_ms",
234 "the probe timeout must be greater than zero",
235 "Use 3000 milliseconds as a practical starting point.",
236 ));
237 }
238 if self.capabilities.network.probe_timeout_ms > 120_000 {
239 issues.push(ValidationIssue::new(
240 "capabilities.network.probe_timeout_ms",
241 "the probe timeout cannot exceed 120000 milliseconds",
242 "Choose a bounded timeout appropriate for a user-facing operation.",
243 ));
244 }
245 if let Some(probe_url) = &self.capabilities.network.probe_url {
246 match url::Url::parse(probe_url) {
247 Ok(url) if matches!(url.scheme(), "http" | "https") => {}
248 Ok(_) => issues.push(ValidationIssue::new(
249 "capabilities.network.probe_url",
250 "only HTTP and HTTPS probe endpoints are supported",
251 "Use an HTTPS health endpoint controlled by the application backend.",
252 )),
253 Err(error) => issues.push(ValidationIssue::new(
254 "capabilities.network.probe_url",
255 format!("the probe endpoint is not an absolute URL: {error}"),
256 "Use an absolute HTTPS URL, or omit it and pass an endpoint explicitly.",
257 )),
258 }
259 }
260 if self.capabilities.notifications.push {
261 issues.push(ValidationIssue::new(
262 "capabilities.notifications.push",
263 "remote push is not implemented in schema version 1",
264 "Set `push = false`; local notifications remain available.",
265 ));
266 }
267 for scheme in &self.capabilities.deep_links.schemes {
268 if !valid_url_scheme(scheme) {
269 issues.push(ValidationIssue::new(
270 "capabilities.deep_links.schemes",
271 format!("`{scheme}` is not a safe custom URL scheme"),
272 "Schemes must start with an ASCII letter and contain only letters, digits, `+`, `-`, or `.`.",
273 ));
274 }
275 }
276 if self.extensions.widget.enabled && self.extensions.widget.app_group.is_none() {
277 issues.push(ValidationIssue::new(
278 "extensions.widget.app_group",
279 "the widget extension needs an application group for shared state",
280 format!("Set `app_group = \"group.{}\"`.", self.app.identifier),
281 ));
282 }
283 if self.extensions.live_activity.enabled
284 && parse_platform_version(&self.ios.min_version)
285 .is_some_and(|version| version < (16, 1))
286 {
287 issues.push(ValidationIssue::new(
288 "ios.min_version",
289 "Live Activities require iOS 16.1 or newer",
290 "Set `ios.min_version = \"16.1\"` or disable the Live Activity extension.",
291 ));
292 }
293 validate_permission(
294 "permissions.camera",
295 &self.permissions.camera,
296 "Explain why the app needs camera access.",
297 &mut issues,
298 );
299 validate_permission(
300 "permissions.photos",
301 &self.permissions.photos,
302 "Explain why the app needs photo-library access.",
303 &mut issues,
304 );
305 validate_permission(
306 "permissions.microphone",
307 &self.permissions.microphone,
308 "Explain why the app needs microphone access.",
309 &mut issues,
310 );
311 validate_permission(
312 "permissions.location_when_in_use",
313 &self.permissions.location_when_in_use,
314 "Explain why the app needs location while in use.",
315 &mut issues,
316 );
317 validate_permission(
318 "permissions.local_network",
319 &self.permissions.local_network,
320 "Explain which local devices or services the app connects to.",
321 &mut issues,
322 );
323 issues
324 }
325
326 pub fn validate_or_error(&self) -> Result<(), ConfigError> {
332 let issues = self.validate();
333 if issues.is_empty() {
334 Ok(())
335 } else {
336 Err(ConfigError::Validation { issues })
337 }
338 }
339}
340
341#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
343#[serde(default, deny_unknown_fields)]
344pub struct PermissionsConfig {
345 pub camera: PermissionConfig,
347 pub photos: PermissionConfig,
349 pub microphone: PermissionConfig,
351 pub location_when_in_use: PermissionConfig,
353 pub local_network: PermissionConfig,
355}
356
357#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
359#[serde(default, deny_unknown_fields)]
360pub struct PermissionConfig {
361 pub enabled: bool,
363 pub purpose: Option<String>,
365}
366
367#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Eq, Hash, PartialEq, Serialize)]
369#[serde(rename_all = "kebab-case")]
370pub enum TargetPlatform {
371 Android,
373 Ios,
375}
376
377fn default_platforms() -> Vec<TargetPlatform> {
378 vec![TargetPlatform::Android, TargetPlatform::Ios]
379}
380
381#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
383#[serde(deny_unknown_fields)]
384pub struct AppConfig {
385 pub name: String,
387 pub identifier: String,
389 pub version: Version,
391 pub display_version: String,
393 #[serde(default)]
395 pub window: AppWindowConfig,
396}
397
398#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
400#[serde(deny_unknown_fields)]
401pub struct AppWindowConfig {
402 #[serde(default)]
404 pub orientation: Orientation,
405 #[serde(default)]
407 pub theme: Theme,
408}
409
410#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
412#[serde(rename_all = "kebab-case")]
413pub enum Orientation {
414 #[default]
416 Automatic,
417 Portrait,
419 Landscape,
421}
422
423#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
425#[serde(rename_all = "kebab-case")]
426pub enum Theme {
427 #[default]
429 System,
430 Light,
432 Dark,
434}
435
436#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
438#[serde(default, deny_unknown_fields)]
439pub struct AndroidConfig {
440 pub min_sdk: u32,
442 pub target_sdk: String,
444 pub abis: Vec<AndroidAbi>,
446}
447
448impl Default for AndroidConfig {
449 fn default() -> Self {
450 Self {
451 min_sdk: 26,
452 target_sdk: "installed".to_owned(),
453 abis: vec![AndroidAbi::Arm64V8a],
454 }
455 }
456}
457
458#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Eq, Hash, PartialEq, Serialize)]
460pub enum AndroidAbi {
461 #[serde(rename = "arm64-v8a")]
463 Arm64V8a,
464 #[serde(rename = "x86_64")]
466 X86_64,
467 #[serde(rename = "armeabi-v7a")]
469 ArmeabiV7a,
470}
471
472impl AndroidAbi {
473 pub const fn rust_target(self) -> &'static str {
475 match self {
476 Self::Arm64V8a => "aarch64-linux-android",
477 Self::X86_64 => "x86_64-linux-android",
478 Self::ArmeabiV7a => "armv7-linux-androideabi",
479 }
480 }
481
482 pub const fn apk_directory(self) -> &'static str {
484 match self {
485 Self::Arm64V8a => "arm64-v8a",
486 Self::X86_64 => "x86_64",
487 Self::ArmeabiV7a => "armeabi-v7a",
488 }
489 }
490}
491
492#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
494#[serde(default, deny_unknown_fields)]
495pub struct IosConfig {
496 pub min_version: String,
498}
499
500impl Default for IosConfig {
501 fn default() -> Self {
502 Self {
503 min_version: "16.0".to_owned(),
504 }
505 }
506}
507
508#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
510#[serde(default, deny_unknown_fields)]
511pub struct CapabilitiesConfig {
512 pub network: NetworkCapability,
514 pub notifications: NotificationCapability,
516 pub storage: BoolCapability,
518 pub haptics: BoolCapability,
520 pub clipboard: BoolCapability,
522 pub share: BoolCapability,
524 pub deep_links: DeepLinksCapability,
526}
527
528impl CapabilitiesConfig {
529 fn starter() -> Self {
530 Self {
531 network: NetworkCapability {
532 mode: NetworkMode::Status,
533 ..NetworkCapability::default()
534 },
535 notifications: NotificationCapability {
536 local: true,
537 push: false,
538 },
539 storage: BoolCapability { enabled: true },
540 haptics: BoolCapability { enabled: true },
541 clipboard: BoolCapability::default(),
542 share: BoolCapability::default(),
543 deep_links: DeepLinksCapability::default(),
544 }
545 }
546}
547
548#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
550#[serde(default, deny_unknown_fields)]
551pub struct BoolCapability {
552 pub enabled: bool,
554}
555
556#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
558#[serde(default, deny_unknown_fields)]
559pub struct NetworkCapability {
560 pub mode: NetworkMode,
562 pub probe_url: Option<String>,
564 pub probe_timeout_ms: u64,
566}
567
568impl Default for NetworkCapability {
569 fn default() -> Self {
570 Self {
571 mode: NetworkMode::None,
572 probe_url: None,
573 probe_timeout_ms: 3_000,
574 }
575 }
576}
577
578#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
580#[serde(rename_all = "kebab-case")]
581pub enum NetworkMode {
582 #[default]
584 None,
585 Status,
587 Optional,
589 Required,
591}
592
593#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
595#[serde(default, deny_unknown_fields)]
596pub struct NotificationCapability {
597 pub local: bool,
599 pub push: bool,
601}
602
603#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
605#[serde(default, deny_unknown_fields)]
606pub struct DeepLinksCapability {
607 pub schemes: Vec<String>,
609 pub allowed_hosts: Vec<String>,
611 pub allowed_actions: Vec<String>,
613}
614
615#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
617#[serde(default, deny_unknown_fields)]
618pub struct ExtensionsConfig {
619 pub widget: WidgetConfig,
621 pub live_activity: LiveActivityConfig,
623}
624
625#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
627#[serde(default, deny_unknown_fields)]
628pub struct WidgetConfig {
629 pub enabled: bool,
631 pub app_group: Option<String>,
633}
634
635#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
637#[serde(default, deny_unknown_fields)]
638pub struct LiveActivityConfig {
639 pub enabled: bool,
641 pub android_fallback: AndroidLiveActivityFallback,
643}
644
645#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
647#[serde(rename_all = "kebab-case")]
648pub enum AndroidLiveActivityFallback {
649 None,
651 #[default]
653 OngoingNotification,
654}
655
656impl Default for LiveActivityConfig {
657 fn default() -> Self {
658 Self {
659 enabled: false,
660 android_fallback: AndroidLiveActivityFallback::OngoingNotification,
661 }
662 }
663}
664
665#[derive(Clone, Debug, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
667pub struct ValidationIssue {
668 pub field: String,
670 pub message: String,
672 pub help: String,
674}
675
676impl ValidationIssue {
677 fn new(field: impl Into<String>, message: impl Into<String>, help: impl Into<String>) -> Self {
678 Self {
679 field: field.into(),
680 message: message.into(),
681 help: help.into(),
682 }
683 }
684}
685
686#[derive(Debug, Error)]
688pub enum ConfigError {
689 #[error("could not read configuration at {path}: {source}")]
691 Read {
692 path: Utf8PathBuf,
694 #[source]
696 source: std::io::Error,
697 },
698 #[error("invalid ferry.toml: {0}")]
700 Parse(#[source] toml::de::Error),
701 #[error("ferry.toml has invalid values")]
703 Validation {
704 issues: Vec<ValidationIssue>,
706 },
707 #[error("could not serialize ferry.toml: {0}")]
709 Serialize(#[source] toml::ser::Error),
710 #[error("could not serialize ferry.toml JSON Schema: {0}")]
712 Schema(#[source] serde_json::Error),
713}
714
715fn parse_platform_version(value: &str) -> Option<(u32, u32)> {
716 let mut parts = value.split('.');
717 let major = parts.next()?.parse().ok()?;
718 let minor = parts.next()?.parse().ok()?;
719 if parts.next().is_some() {
720 return None;
721 }
722 Some((major, minor))
723}
724
725fn valid_url_scheme(scheme: &str) -> bool {
726 let mut characters = scheme.chars();
727 characters
728 .next()
729 .is_some_and(|first| first.is_ascii_alphabetic())
730 && characters.all(|character| {
731 character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
732 })
733}
734
735fn validate_permission(
736 field: &'static str,
737 permission: &PermissionConfig,
738 help: &'static str,
739 issues: &mut Vec<ValidationIssue>,
740) {
741 if permission.enabled
742 && permission
743 .purpose
744 .as_deref()
745 .is_none_or(|purpose| purpose.trim().is_empty())
746 {
747 issues.push(ValidationIssue::new(
748 format!("{field}.purpose"),
749 "an enabled permission requires a non-empty purpose string",
750 help,
751 ));
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn starter_configuration_round_trips() {
761 let config = FerryConfig::starter("Weather", "com.example.weather");
762 let encoded = config.to_pretty_toml().unwrap();
763 let decoded = FerryConfig::parse(&encoded).unwrap();
764 assert_eq!(decoded, config);
765 assert!(decoded.validate().is_empty());
766 }
767
768 #[test]
769 fn unknown_fields_are_rejected() {
770 let source = FerryConfig::starter("Weather", "com.example.weather")
771 .to_pretty_toml()
772 .unwrap()
773 + "\nunknown = true\n";
774 assert!(FerryConfig::parse(&source).is_err());
775 }
776
777 #[test]
778 fn reports_capability_conflicts_together() {
779 let mut config = FerryConfig::starter("Weather", "com.example.weather");
780 config.capabilities.network.mode = NetworkMode::None;
781 config.capabilities.network.probe_url = Some("https://example.invalid/health".to_owned());
782 config.capabilities.notifications.push = true;
783 assert_eq!(config.validate().len(), 2);
784 }
785
786 #[test]
787 fn schema_is_valid_json() {
788 let schema = FerryConfig::json_schema().unwrap();
789 let value: serde_json::Value = serde_json::from_str(&schema).unwrap();
790 assert_eq!(value["title"], "FerryConfig");
791 }
792
793 #[test]
794 fn enabled_permissions_require_purpose_text() {
795 let mut config = FerryConfig::starter("Weather", "com.example.weather");
796 config.permissions.camera.enabled = true;
797 assert!(
798 config
799 .validate()
800 .iter()
801 .any(|issue| issue.field == "permissions.camera.purpose")
802 );
803 config.permissions.camera.purpose = Some("Scan a profile photo".to_owned());
804 assert!(config.validate().is_empty());
805 }
806}