1use std::collections::BTreeMap;
36use std::path::Path;
37
38use serde::{Deserialize, Serialize};
39
40use crate::error::NativeError;
41
42pub const SCHEMA_VERSION: u32 = 1;
44
45pub const TARGETS: &[&str] = &["windows", "android"];
47
48pub const MIN_ANDROID_SDK: u32 = 24;
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct NativeConfig {
55 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
57 pub schema_ref: Option<String>,
58
59 pub schema: u32,
63
64 pub product_name: String,
66
67 pub identifier: String,
69
70 pub version: String,
72
73 pub targets: Vec<String>,
75
76 #[serde(default)]
77 pub window: WindowConfig,
78
79 #[serde(default)]
80 pub android: AndroidConfig,
81
82 #[serde(default)]
83 pub bundle: BundleConfig,
84
85 #[serde(default)]
86 pub database: DatabaseConfig,
87
88 #[serde(default)]
89 pub auth: AuthConfig,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub local: Option<String>,
102
103 #[serde(default)]
104 pub security: SecurityConfig,
105
106 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117 pub scaffold: BTreeMap<String, String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct WindowConfig {
123 pub title: String,
124 pub width: u32,
125 pub height: u32,
126 #[serde(default = "yes")]
129 pub resizable: bool,
130}
131
132impl Default for WindowConfig {
133 fn default() -> Self {
134 WindowConfig {
135 title: "Rahti".to_string(),
136 width: 1200,
137 height: 800,
138 resizable: true,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct AndroidConfig {
146 pub min_sdk: u32,
148}
149
150impl Default for AndroidConfig {
151 fn default() -> Self {
152 AndroidConfig {
153 min_sdk: MIN_ANDROID_SDK,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct BundleConfig {
161 pub icons: String,
163}
164
165impl Default for BundleConfig {
166 fn default() -> Self {
167 BundleConfig {
168 icons: "native/icons".to_string(),
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "kebab-case")]
176pub enum DatabaseMode {
177 SqliteLocal,
182 Remote,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct DatabaseConfig {
194 pub mode: DatabaseMode,
195}
196
197impl Default for DatabaseConfig {
198 fn default() -> Self {
199 DatabaseConfig {
200 mode: DatabaseMode::SqliteLocal,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207pub struct AuthConfig {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub cookie_name: Option<String>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "camelCase", deny_unknown_fields)]
216pub struct SecurityConfig {
217 #[serde(default = "yes")]
220 pub loopback_token: bool,
221
222 #[serde(default = "default_csp")]
229 pub csp: String,
230}
231
232impl Default for SecurityConfig {
233 fn default() -> Self {
234 SecurityConfig {
235 loopback_token: true,
236 csp: default_csp(),
237 }
238 }
239}
240
241pub fn default_csp() -> String {
270 "default-src 'self'; \
271 script-src 'self' 'unsafe-eval'; \
272 style-src 'self' 'unsafe-inline'; \
273 img-src 'self' data: blob:; \
274 font-src 'self' data:; \
275 connect-src 'self' ws: http://127.0.0.1:*; \
276 frame-ancestors 'none'; \
277 object-src 'none'; \
278 base-uri 'self'"
279 .to_string()
280}
281
282const SUPERSEDED_CSPS: &[&str] = &[
295 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
298 img-src 'self' data: blob:; font-src 'self' data:; \
299 connect-src 'self' ws: http://127.0.0.1:*; frame-ancestors 'none'; \
300 object-src 'none'; base-uri 'self'",
301];
302
303pub fn superseded_csp(csp: &str) -> bool {
308 SUPERSEDED_CSPS.contains(&csp)
309}
310
311fn yes() -> bool {
312 true
313}
314
315impl NativeConfig {
316 pub fn new(product_name: &str, identifier: &str, version: &str, targets: &[&str]) -> Self {
318 NativeConfig {
319 schema_ref: Some("./rahti.native.schema.json".to_string()),
320 schema: SCHEMA_VERSION,
321 product_name: product_name.to_string(),
322 identifier: identifier.to_string(),
323 version: version.to_string(),
324 targets: targets.iter().map(|t| t.to_string()).collect(),
325 window: WindowConfig {
326 title: product_name.to_string(),
327 ..WindowConfig::default()
328 },
329 android: AndroidConfig::default(),
330 bundle: BundleConfig::default(),
331 database: DatabaseConfig::default(),
332 auth: AuthConfig::default(),
333 local: None,
334 security: SecurityConfig::default(),
335 scaffold: BTreeMap::new(),
336 }
337 }
338
339 pub fn load(path: &Path) -> Result<Self, NativeError> {
341 let text = std::fs::read_to_string(path).map_err(|e| {
342 if e.kind() == std::io::ErrorKind::NotFound {
343 NativeError::at(
344 "config",
345 path,
346 "this project has no native configuration.\n \
347 Create one with:\n \
348 cargo rahti native init --identifier com.example.myapp --windows",
349 )
350 } else {
351 NativeError::io("config", path, e)
352 }
353 })?;
354
355 Self::parse(&text).map_err(|mut e| {
356 e.path = Some(path.to_path_buf());
357 e
358 })
359 }
360
361 pub fn parse(text: &str) -> Result<Self, NativeError> {
369 let mut config: NativeConfig = serde_json::from_str(text).map_err(|e| {
370 NativeError::new("config", format!("rahti.native.json is not valid: {e}"))
371 })?;
372 config.migrate();
373 config.validate()?;
374 Ok(config)
375 }
376
377 fn migrate(&mut self) {
382 if superseded_csp(&self.security.csp) {
383 self.security.csp = default_csp();
384 }
385 }
386
387 pub fn to_json(&self) -> String {
389 let mut text = serde_json::to_string_pretty(self).expect("a configuration serializes");
390 text.push('\n');
391 text
392 }
393
394 pub fn validate(&self) -> Result<(), NativeError> {
396 let fail = |message: String| NativeError::new("config", message);
397
398 if self.schema != SCHEMA_VERSION {
399 return Err(fail(format!(
400 "rahti.native.json has `schema` {}, and this tool understands {SCHEMA_VERSION}.\n \
401 Upgrade cargo-rahti-native, or regenerate the file with `cargo rahti native init`.",
402 self.schema
403 )));
404 }
405
406 check_product_name(&self.product_name).map_err(fail)?;
407 check_identifier(&self.identifier).map_err(fail)?;
408 check_version(&self.version).map_err(fail)?;
409
410 if self.targets.is_empty() {
411 return Err(fail(
412 "`targets` is empty, so there is nothing to build.\n \
413 Add \"windows\", \"android\", or both."
414 .to_string(),
415 ));
416 }
417 for target in &self.targets {
418 if !TARGETS.contains(&target.as_str()) {
419 return Err(fail(format!(
420 "`{target}` is not a native target. Rahti packages {}.",
421 TARGETS.join(" and ")
422 )));
423 }
424 }
425
426 if self.window.width == 0 || self.window.height == 0 {
427 return Err(fail(
428 "a window with a zero dimension has nothing to show.".to_string(),
429 ));
430 }
431
432 if self.android.min_sdk < MIN_ANDROID_SDK {
433 return Err(fail(format!(
434 "`android.minSdk` is {}, and Tauri 2 needs at least {MIN_ANDROID_SDK}.",
435 self.android.min_sdk
436 )));
437 }
438
439 if let Some(cookie) = &self.auth.cookie_name {
440 check_cookie_name(cookie).map_err(fail)?;
441 }
442
443 if self.security.csp.trim().is_empty() {
444 return Err(fail(
445 "`security.csp` is empty. A package that serves no Content-Security-Policy \
446 puts an XSS in reach of the native command bridge — set a policy, or remove \
447 the field to take the default."
448 .to_string(),
449 ));
450 }
451
452 let scripts = self
457 .security
458 .csp
459 .split(';')
460 .map(str::trim)
461 .find(|directive| directive.starts_with("script-src"));
462 if let Some(scripts) = scripts
463 && !scripts.contains("'unsafe-eval'")
464 {
465 return Err(fail(format!(
466 "`security.csp` has `{scripts}`, and PulsePoint cannot run under it.\n \
467 It compiles the expressions in a reactive block at runtime, with \
468 `new Function` — which a Content-Security-Policy counts as evaluating a \
469 string as JavaScript.\n \
470 Without `'unsafe-eval'` the page renders and every binding on it is \
471 dead, reporting an EvalError from inside the runtime bundle.\n \
472 Add `'unsafe-eval'` to `script-src`. It stays narrow: `script-src` still \
473 refuses every *source* but this origin, so injected markup cannot load \
474 an attacker's file."
475 )));
476 }
477
478 Ok(())
479 }
480
481 pub fn builds(&self, target: &str) -> bool {
483 self.targets.iter().any(|t| t == target)
484 }
485
486 pub fn android_version_code(&self) -> u32 {
492 let mut parts = self
493 .version
494 .split('.')
495 .map(|p| p.parse::<u32>().unwrap_or(0));
496 let major = parts.next().unwrap_or(0);
497 let minor = parts.next().unwrap_or(0);
498 let patch = parts.next().unwrap_or(0);
499 major * 10_000 + minor * 100 + patch
500 }
501}
502
503pub fn check_identifier(identifier: &str) -> Result<(), String> {
511 let advice = " An identifier is reverse-DNS and has to be a legal Android package name: \
512 at least two segments, each starting with a letter and made of letters, \
513 digits and `_`. No hyphens.\n \
514 For example: com.example.myapp";
515
516 if identifier.trim() != identifier || identifier.is_empty() {
517 return Err(format!(
518 "`{identifier}` is not an application identifier.\n{advice}"
519 ));
520 }
521
522 let segments: Vec<&str> = identifier.split('.').collect();
523 if segments.len() < 2 {
524 return Err(format!(
525 "`{identifier}` has one segment, and an identifier needs at least two.\n{advice}"
526 ));
527 }
528
529 for segment in &segments {
530 if segment.is_empty() {
531 return Err(format!("`{identifier}` has an empty segment.\n{advice}"));
532 }
533 if !segment.starts_with(|c: char| c.is_ascii_alphabetic()) {
534 return Err(format!(
535 "`{identifier}`: the segment `{segment}` does not start with a letter.\n{advice}"
536 ));
537 }
538 if !segment
539 .chars()
540 .all(|c| c.is_ascii_alphanumeric() || c == '_')
541 {
542 return Err(format!(
543 "`{identifier}`: the segment `{segment}` has a character that is not a letter, \
544 a digit or `_`.\n{advice}"
545 ));
546 }
547 if JAVA_KEYWORDS.contains(segment) {
548 return Err(format!(
549 "`{identifier}`: `{segment}` is a Java keyword, which an Android package name \
550 cannot contain.\n{advice}"
551 ));
552 }
553 }
554
555 if identifier == "com.tauri.dev" {
559 return Err(
560 "`com.tauri.dev` is Tauri's placeholder identifier, and every application using it \
561 would replace every other one on the device.\n \
562 Use your own reverse-DNS identifier, for example com.example.myapp."
563 .to_string(),
564 );
565 }
566
567 Ok(())
568}
569
570const JAVA_KEYWORDS: &[&str] = &[
572 "abstract",
573 "assert",
574 "boolean",
575 "break",
576 "byte",
577 "case",
578 "catch",
579 "char",
580 "class",
581 "const",
582 "continue",
583 "default",
584 "do",
585 "double",
586 "else",
587 "enum",
588 "extends",
589 "final",
590 "finally",
591 "float",
592 "for",
593 "goto",
594 "if",
595 "implements",
596 "import",
597 "instanceof",
598 "int",
599 "interface",
600 "long",
601 "native",
602 "new",
603 "package",
604 "private",
605 "protected",
606 "public",
607 "return",
608 "short",
609 "static",
610 "strictfp",
611 "super",
612 "switch",
613 "synchronized",
614 "this",
615 "throw",
616 "throws",
617 "transient",
618 "try",
619 "void",
620 "volatile",
621 "while",
622];
623
624pub fn check_product_name(name: &str) -> Result<(), String> {
626 if name.trim().is_empty() {
627 return Err(
628 "`productName` is empty, and it is what the installed application is \
629 called."
630 .to_string(),
631 );
632 }
633 if name.trim() != name {
634 return Err(format!(
635 "`productName` is `{name}`, which has leading or trailing whitespace. It becomes a \
636 filename, where that does not survive."
637 ));
638 }
639 const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
641 if let Some(bad) = name
642 .chars()
643 .find(|c| FORBIDDEN.contains(c) || c.is_control())
644 {
645 return Err(format!(
646 "`productName` contains `{bad}`, which cannot be in a filename — and the product \
647 name becomes one."
648 ));
649 }
650 Ok(())
651}
652
653pub fn check_version(version: &str) -> Result<(), String> {
659 let parts: Vec<&str> = version.split('.').collect();
660 let numeric = parts.len() == 3
661 && parts
662 .iter()
663 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
664
665 if !numeric {
666 return Err(format!(
667 "`version` is `{version}`, and a native package needs `major.minor.patch` with all \
668 three numeric.\n \
669 A Windows installer version is three numbers, and Google Play orders releases by an \
670 integer derived from them — a pre-release suffix has nowhere to go in either."
671 ));
672 }
673
674 for part in parts {
675 if part.parse::<u32>().is_err() {
676 return Err(format!(
677 "`version` is `{version}`, and `{part}` is too large."
678 ));
679 }
680 }
681 Ok(())
682}
683
684fn check_cookie_name(name: &str) -> Result<(), String> {
688 const SEPARATORS: &[char] = &[
689 '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ',
690 ];
691 if name.is_empty() {
692 return Err("`auth.cookieName` is empty.".to_string());
693 }
694 if name
695 .chars()
696 .any(|c| c.is_control() || SEPARATORS.contains(&c) || !c.is_ascii())
697 {
698 return Err(format!(
699 "`auth.cookieName` is `{name}`, which is not a legal cookie name.\n \
700 It has to be a token: letters, digits, and `-_.~!#$%&'*+^|`."
701 ));
702 }
703 Ok(())
704}