Skip to main content

reinhardt_deeplink/
settings.rs

1//! Settings fragment for the deeplink subsystem.
2//!
3//! This module defines the [`DeeplinkSettings`] fragment, which is the
4//! settings-based replacement for the deprecated [`crate::config::DeeplinkConfig`].
5//!
6//! The fragment integrates with `reinhardt-conf`'s layered settings system and
7//! can be loaded from configuration files, environment variables, and other
8//! sources via the `#[settings]` macro.
9
10// The conversions in this module bridge the `#[settings]` fragments into the
11// deprecated `DeeplinkConfig` for backward compatibility during the 0.2 window.
12// Remove this allowance once `DeeplinkConfig` is deleted.
13#![allow(deprecated)]
14
15use reinhardt_core::macros::settings;
16use serde::{Deserialize, Serialize};
17
18use crate::config::{
19	AndroidConfig, AndroidConfigBuilder, CustomScheme, DeeplinkConfig, IosConfig, IosConfigBuilder,
20};
21
22/// iOS Universal Links configuration for the [`DeeplinkSettings`] fragment.
23///
24/// This is a nested value object embedded in [`DeeplinkSettings`]; it is never
25/// loaded from its own configuration section. Its fields mirror the inputs
26/// accepted by [`IosConfigBuilder`], and it is converted into an [`IosConfig`]
27/// through that builder.
28#[settings(fragment = true)]
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
30pub struct IosSettings {
31	/// The app identifier in the format `TEAMID.BUNDLEID`.
32	#[serde(default)]
33	pub app_id: String,
34	/// URL paths that should open the app.
35	#[serde(default)]
36	pub paths: Vec<String>,
37	/// URL paths that should NOT open the app.
38	#[serde(default)]
39	pub exclude_paths: Vec<String>,
40	/// App Clip bundle identifiers (usually ending in `.Clip`).
41	#[serde(default)]
42	pub app_clips: Vec<String>,
43	/// Whether to enable web credentials (password autofill) for the app id.
44	#[serde(default)]
45	pub web_credentials: bool,
46}
47
48/// Android App Links configuration for the [`DeeplinkSettings`] fragment.
49///
50/// This is a nested value object embedded in [`DeeplinkSettings`]; it is never
51/// loaded from its own configuration section. Its fields mirror the inputs
52/// accepted by [`AndroidConfigBuilder`], and it is converted into an
53/// [`AndroidConfig`] through that builder.
54#[settings(fragment = true)]
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56pub struct AndroidSettings {
57	/// The Android app package name (e.g., `com.example.app`).
58	#[serde(default)]
59	pub package_name: String,
60	/// SHA-256 certificate fingerprints.
61	#[serde(default)]
62	pub sha256_cert_fingerprints: Vec<String>,
63}
64
65/// A single custom URL scheme for the [`DeeplinkSettings`] fragment.
66///
67/// This is a nested value object embedded in [`DeeplinkSettings`]; it is never
68/// loaded from its own configuration section.
69#[settings(fragment = true)]
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
71pub struct CustomSchemeSettings {
72	/// The scheme name (e.g., `myapp`).
73	#[serde(default)]
74	pub name: String,
75	/// Allowed hosts for the scheme.
76	#[serde(default)]
77	pub hosts: Vec<String>,
78	/// Allowed paths for the scheme.
79	#[serde(default)]
80	pub paths: Vec<String>,
81}
82
83/// Settings fragment for configuring the deeplink subsystem.
84///
85/// This fragment unifies the iOS, Android, and custom-scheme configuration into
86/// a single loadable section and replaces the deprecated
87/// [`crate::config::DeeplinkConfig`].
88#[settings(fragment = true, section = "deeplink_app")]
89#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub struct DeeplinkSettings {
91	/// iOS Universal Links configuration (optional).
92	#[setting(node)]
93	#[serde(default, skip_serializing_if = "Option::is_none")]
94	pub ios: Option<IosSettings>,
95	/// Android App Links configuration (optional).
96	#[setting(node)]
97	#[serde(default, skip_serializing_if = "Option::is_none")]
98	pub android: Option<AndroidSettings>,
99	/// Custom URL scheme configurations.
100	#[setting(node)]
101	#[serde(default)]
102	pub custom_schemes: Vec<CustomSchemeSettings>,
103}
104
105impl IosSettings {
106	/// Builds an [`IosConfig`] from these settings via [`IosConfigBuilder`].
107	fn to_config(&self) -> IosConfig {
108		let path_refs: Vec<&str> = self.paths.iter().map(String::as_str).collect();
109		let exclude_refs: Vec<&str> = self.exclude_paths.iter().map(String::as_str).collect();
110
111		let mut builder = IosConfigBuilder::new()
112			.app_id(self.app_id.clone())
113			.paths(&path_refs)
114			.exclude_paths(&exclude_refs);
115
116		for app_clip in &self.app_clips {
117			builder = builder.app_clip(app_clip.clone());
118		}
119
120		if self.web_credentials {
121			builder = builder.with_web_credentials();
122		}
123
124		builder.build()
125	}
126}
127
128impl AndroidSettings {
129	/// Builds an [`AndroidConfig`] from these settings via
130	/// [`AndroidConfigBuilder`].
131	///
132	/// This uses the builder's unchecked path; fingerprint and package-name
133	/// validation are performed by the settings/configuration layer rather than
134	/// at conversion time.
135	fn to_config(&self) -> AndroidConfig {
136		let fingerprint_refs: Vec<&str> = self
137			.sha256_cert_fingerprints
138			.iter()
139			.map(String::as_str)
140			.collect();
141
142		AndroidConfigBuilder::new()
143			.package_name(self.package_name.clone())
144			.sha256_fingerprints(&fingerprint_refs)
145			.build_unchecked()
146	}
147}
148
149impl From<&CustomSchemeSettings> for CustomScheme {
150	fn from(settings: &CustomSchemeSettings) -> Self {
151		CustomScheme {
152			name: settings.name.clone(),
153			hosts: settings.hosts.clone(),
154			paths: settings.paths.clone(),
155		}
156	}
157}
158
159impl From<&DeeplinkSettings> for DeeplinkConfig {
160	fn from(settings: &DeeplinkSettings) -> Self {
161		DeeplinkConfig {
162			ios: settings.ios.as_ref().map(IosSettings::to_config),
163			android: settings.android.as_ref().map(AndroidSettings::to_config),
164			custom_schemes: settings
165				.custom_schemes
166				.iter()
167				.map(CustomScheme::from)
168				.collect(),
169		}
170	}
171}
172
173/// Creates a deeplink configuration from the settings fragment.
174pub fn create_deeplink_config_from_settings(settings: &DeeplinkSettings) -> DeeplinkConfig {
175	DeeplinkConfig::from(settings)
176}
177
178#[cfg(test)]
179mod tests {
180	use rstest::rstest;
181
182	use super::*;
183
184	const VALID_FINGERPRINT: &str = "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C";
185
186	#[rstest]
187	fn test_default_settings() {
188		let settings = DeeplinkSettings::default();
189		assert!(settings.ios.is_none());
190		assert!(settings.android.is_none());
191		assert!(settings.custom_schemes.is_empty());
192	}
193
194	#[rstest]
195	fn test_default_settings_bridge_to_empty_config() {
196		let settings = DeeplinkSettings::default();
197		let config = create_deeplink_config_from_settings(&settings);
198		assert!(!config.is_configured());
199	}
200
201	#[rstest]
202	fn test_ios_bridge() {
203		let settings = DeeplinkSettings {
204			ios: Some(IosSettings {
205				app_id: "TEAM.com.example".to_string(),
206				paths: vec!["/products/*".to_string()],
207				exclude_paths: vec!["/api/*".to_string()],
208				app_clips: vec!["TEAM.com.example.Clip".to_string()],
209				web_credentials: true,
210			}),
211			..Default::default()
212		};
213		let config = DeeplinkConfig::from(&settings);
214		assert!(config.has_ios());
215		let ios = config.ios.expect("ios config present");
216		let json = serde_json::to_string(&ios).expect("serialize ios config");
217		assert!(json.contains("TEAM.com.example"));
218		assert!(json.contains("/products/*"));
219		assert!(json.contains("/api/*"));
220		assert!(json.contains("webcredentials"));
221		assert!(json.contains("appclips"));
222	}
223
224	#[rstest]
225	fn test_android_bridge() {
226		let settings = DeeplinkSettings {
227			android: Some(AndroidSettings {
228				package_name: "com.example.app".to_string(),
229				sha256_cert_fingerprints: vec![VALID_FINGERPRINT.to_string()],
230			}),
231			..Default::default()
232		};
233		let config = DeeplinkConfig::from(&settings);
234		assert!(config.has_android());
235		let android = config.android.expect("android config present");
236		assert_eq!(android.statements.len(), 1);
237		assert_eq!(android.statements[0].target.package_name, "com.example.app");
238		assert_eq!(
239			android.statements[0].target.sha256_cert_fingerprints,
240			vec![VALID_FINGERPRINT.to_string()]
241		);
242	}
243
244	#[rstest]
245	fn test_custom_schemes_bridge() {
246		let settings = DeeplinkSettings {
247			custom_schemes: vec![CustomSchemeSettings {
248				name: "myapp".to_string(),
249				hosts: vec!["open".to_string()],
250				paths: vec!["/products/*".to_string()],
251			}],
252			..Default::default()
253		};
254		let config = DeeplinkConfig::from(&settings);
255		assert!(config.has_custom_schemes());
256		assert_eq!(config.custom_schemes.len(), 1);
257		assert_eq!(config.custom_schemes[0].name, "myapp");
258		assert_eq!(config.custom_schemes[0].hosts, vec!["open".to_string()]);
259		assert_eq!(
260			config.custom_schemes[0].paths,
261			vec!["/products/*".to_string()]
262		);
263	}
264
265	#[rstest]
266	fn test_settings_roundtrip_serde() {
267		let settings = DeeplinkSettings {
268			ios: Some(IosSettings {
269				app_id: "TEAM.com.example".to_string(),
270				paths: vec!["/".to_string()],
271				exclude_paths: Vec::new(),
272				app_clips: Vec::new(),
273				web_credentials: false,
274			}),
275			android: None,
276			custom_schemes: Vec::new(),
277		};
278		let json = serde_json::to_string(&settings).expect("serialize settings");
279		let parsed: DeeplinkSettings = serde_json::from_str(&json).expect("deserialize settings");
280		assert_eq!(settings, parsed);
281	}
282}