1use std::{
4 env, fs,
5 io::{self, BufRead, Write},
6 mem,
7 path::{Path, PathBuf},
8 process::Command,
9};
10
11use convert_case::{Case, Casing};
12
13use crate::util;
14
15pub const ZR_WORKSPACE_DIR: &str = "ZR_WORKSPACE_DIR";
19pub const ZR_SOURCE_DIR: &str = "ZR_SOURCE_DIR";
21pub const ZR_TARGET_DIR: &str = "ZR_TARGET_DIR";
26pub const ZR_CACHE_DIR: &str = "ZR_CACHE_DIR";
30
31pub const ZR_REQUEST: &str = "ZR_REQUEST";
33pub const ZR_REQUEST_DD: &str = "ZR_REQUEST_DD";
35pub const ZR_TARGET: &str = "ZR_TARGET";
39pub const ZR_TARGET_DD: &str = "ZR_TARGET_DD";
41
42pub const ZR_FINAL: &str = "ZR_FINAL";
44
45pub const ZR_HELP: &str = "ZR_HELP";
47
48pub const ZR_APP: &str = "ZR_APP";
50pub const ZR_ORG: &str = "ZR_ORG";
52pub const ZR_VERSION: &str = "ZR_VERSION";
54pub const ZR_DESCRIPTION: &str = "ZR_DESCRIPTION";
56pub const ZR_HOMEPAGE: &str = "ZR_HOMEPAGE";
58pub const ZR_LICENSE: &str = "ZR_LICENSE";
60pub const ZR_PKG_NAME: &str = "ZR_PKG_NAME";
62pub const ZR_PKG_AUTHORS: &str = "ZR_PKG_AUTHORS";
64pub const ZR_CRATE_NAME: &str = "ZR_CRATE_NAME";
66pub const ZR_QUALIFIER: &str = "ZR_QUALIFIER";
68
69pub fn help(help: &str) {
71 if env::var(ZR_HELP).is_ok() {
72 println!("{help}");
73 std::process::exit(0);
74 };
75}
76
77pub fn path(var: &str) -> PathBuf {
79 env::var(var).unwrap_or_else(|_| panic!("missing {var}")).into()
80}
81
82pub fn display_path(p: &Path) -> String {
84 let base = path(ZR_WORKSPACE_DIR);
85 let r = if let Ok(local) = p.strip_prefix(base) {
86 local.display().to_string()
87 } else {
88 p.display().to_string()
89 };
90
91 #[cfg(windows)]
92 return r.replace('\\', "/");
93
94 #[cfg(not(windows))]
95 r
96}
97
98const COPY_HELP: &str = "
99Copy the file or dir
100
101The request file:
102 source/foo.txt.zr-copy
103 | # comment
104 | path/bar.txt
105
106Copies `path/bar.txt` to:
107 target/foo.txt
108
109Paths are relative to the Cargo workspace root.
110";
111fn copy() {
112 help(COPY_HELP);
113
114 let source = read_path(&path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
116 let mut target = path(ZR_TARGET);
118 if target.ends_with(".zr-copy") {
120 target = target.with_file_name(source.file_name().unwrap());
121 }
122
123 if source.is_dir() {
124 println!("{}", display_path(&target));
125 fs::create_dir(&target).unwrap_or_else(|e| {
126 if e.kind() != io::ErrorKind::AlreadyExists {
127 fatal!("{e}")
128 }
129 });
130 copy_dir_all(&source, &target, true);
131 } else if source.is_file() {
132 println!("{}", display_path(&target));
133 fs::copy(source, &target).unwrap_or_else(|e| fatal!("{e}"));
134 } else if source.is_symlink() {
135 symlink_warn(&source);
136 } else {
137 warn!("cannot copy '{}', not found", source.display());
138 }
139}
140
141const GLOB_HELP: &str = "
142Copy all matches in place
143
144The request file:
145 source/l10n/fluent-files.zr-glob
146 | # localization dir
147 | l10n
148 | # only Fluent files
149 | **/*.ftl
150 | # except test locales
151 | !:**/pseudo*
152
153Copies all '.ftl' not in a *pseudo* path to:
154 target/l10n/
155
156The first path pattern is required and defines the entries that
157will be copied, an initial pattern with '**' flattens the matches.
158The path is relative to the Cargo workspace root.
159
160The subsequent patterns are optional and filter each file or dir selected by
161the first pattern. The paths are relative to each match, if it is a file
162the filters apply to the file name only, if it is a dir the filters apply to
163the dir and descendants.
164
165The glob pattern syntax is:
166
167 ? — matches any single character.
168 * — matches any (possibly empty) sequence of characters.
169 ** — matches the current directory and arbitrary subdirectories.
170 [c] — matches any character inside the brackets.
171[a-z] — matches any characters in the Unicode sequence.
172 [!b] — negates the brackets match.
173
174And in filter patterns only:
175
176!:pattern — negates the entire pattern.
177
178";
179fn glob() {
180 help(GLOB_HELP);
181
182 let target = path(ZR_TARGET);
184 let target = target.parent().unwrap();
185
186 let request_path = path(ZR_REQUEST);
187 let mut lines = read_lines(&request_path);
188 let (ln, selection) = lines
189 .next()
190 .unwrap_or_else(|| fatal!("expected at least one path pattern"))
191 .unwrap_or_else(|e| fatal!("{e}"));
192
193 let selection = glob::glob(&selection).unwrap_or_else(|e| fatal!("at line {ln}, {e}"));
195 let mut filters = vec![];
197 for r in lines {
198 let (ln, filter) = r.unwrap_or_else(|e| fatal!("{e}"));
199 let (filter, matches_if) = if let Some(f) = filter.strip_prefix("!:") {
200 (f, false)
201 } else {
202 (filter.as_str(), true)
203 };
204 let pat = glob::Pattern::new(filter).unwrap_or_else(|e| fatal!("at line {ln}, {e}"));
205 filters.push((pat, matches_if));
206 }
207 let selection = {
209 let mut s = vec![];
210 for entry in selection {
211 s.push(entry.unwrap_or_else(|e| fatal!("{e}")));
212 }
213 s.sort();
215 s
216 };
217
218 let mut any = false;
219
220 'apply: for source in selection {
221 if source.is_dir() {
222 let filters_root = source.parent().map(Path::to_owned).unwrap_or_default();
223 'copy_dir: for entry in walkdir::WalkDir::new(&source).sort_by_file_name() {
224 let source = entry.unwrap_or_else(|e| fatal!("cannot walkdir entry `{}`, {e}", source.display()));
225 let source = source.path();
226 let match_source = source.strip_prefix(&filters_root).unwrap();
228 for (filter, matches_if) in &filters {
229 if filter.matches_path(match_source) != *matches_if {
230 continue 'copy_dir;
231 }
232 }
233 let target = target.join(match_source);
234
235 any = true;
236 if source.is_dir() {
237 fs::create_dir_all(&target).unwrap_or_else(|e| fatal!("cannot create dir `{}`, {e}", source.display()));
238 } else {
239 if let Some(p) = &target.parent() {
240 fs::create_dir_all(p).unwrap_or_else(|e| fatal!("cannot create dir `{}`, {e}", p.display()));
241 }
242 fs::copy(source, &target)
243 .unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", source.display(), target.display()));
244 }
245 println!("{}", display_path(&target));
246 }
247 } else if source.is_file() {
248 let source_name = source.file_name().unwrap().to_string_lossy();
250 for (filter, matches_if) in &filters {
251 if filter.matches(&source_name) != *matches_if {
252 continue 'apply;
253 }
254 }
255 let target = target.join(source_name.as_ref());
256
257 any = true;
258 fs::copy(&source, &target).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", source.display(), target.display()));
259 println!("{}", display_path(&target));
260 } else if source.is_symlink() {
261 symlink_warn(&source);
262 }
263 }
264
265 if !any {
266 warn!("no match")
267 }
268}
269
270const RP_HELP: &str = "
271Replace ${VAR|<file|!cmd} occurrences in the content
272
273The request file:
274 source/greetings.txt.zr-rp
275 | Thanks for using ${ZR_APP}!
276
277Writes the text content with ZR_APP replaced:
278 target/greetings.txt
279 | Thanks for using Foo App!
280
281The parameters syntax is ${VAR|!|<[:[case]][?else]}:
282
283${VAR} — Replaces with the env var value, or fails if it is not set.
284${VAR:case} — Replaces with the env var value, case converted.
285${VAR:?else} — If VAR is not set or is empty uses 'else' instead.
286
287${<file.txt} — Replaces with the 'file.txt' content.
288 Paths are relative to the workspace root.
289${<file:case} — Replaces with the 'file.txt' content, case converted.
290${<file:?else} — If file cannot be read or is empty uses 'else' instead.
291
292${!cmd -h} — Replaces with the stdout of the bash script line.
293 The script runs the same bash used by '.zr-sh'.
294 The script must be defined all in one line.
295 A separate bash instance is used for each occurrence.
296 The working directory is the workspace root.
297${!cmd:case} — Replaces with the stdout, case converted.
298 If the script contains ':' quote it with double quotes\"
299$!{!cmd:?else} — If script fails or ha no stdout, uses 'else' instead.
300
301$${VAR} — Escapes $, replaces with '${VAR}'.
302
303The :case functions are:
304
305:k or :kebab — kebab-case (cleaned)
306:K or :KEBAB — UPPER-KEBAB-CASE (cleaned)
307:s or :snake — snake_case (cleaned)
308:S or :SNAKE — UPPER_SNAKE_CASE (cleaned)
309:l or :lower — lower case
310:U or :UPPER — UPPER CASE
311:T or :Title — Title Case
312:c or :camel — camelCase (cleaned)
313:P or :Pascal — PascalCase (cleaned)
314:Tr or :Train — Train-Case (cleaned)
315: — Unchanged
316:clean — Cleaned
317:f or :file — Sanitize file name
318
319Cleaned values only keep ascii alphabetic first char and ascii alphanumerics, ' ', '-' and '_' other chars.
320More then one case function can be used, separated by pipe ':T|f' converts to title case and sanitize for file name.
321
322
323The fallback(:?else) can have nested ${...} patterns.
324You can set both case and else: '${VAR:case?else}'.
325
326Variables:
327
328All env variables can be used, of particular use with this tool are:
329
330ZR_APP — package.metadata.zng.about.app or package.name
331ZR_ORG — package.metadata.zng.about.org or the first package.authors
332ZR_VERSION — package.version
333ZR_DESCRIPTION — package.description
334ZR_HOMEPAGE — package.homepage
335ZR_LICENSE — package.license
336ZR_PKG_NAME — package.name
337ZR_PKG_AUTHORS — package.authors
338ZR_CRATE_NAME — package.name in snake_case
339ZR_QUALIFIER — package.metadata.zng.about.qualifier
340
341See `zng::env::about` for more details about metadata vars.
342See the cargo-zng crate docs for a full list of ZR vars.
343
344";
345fn rp() {
346 help(RP_HELP);
347
348 let content = fs::File::open(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("cannot read, {e}"));
350 let target = path(ZR_TARGET);
351 let target = fs::File::create(target).unwrap_or_else(|e| fatal!("cannot write, {e}"));
352 let mut target = io::BufWriter::new(target);
353
354 let mut content = io::BufReader::new(content);
355 let mut line = String::new();
356 let mut ln = 1;
357 while content.read_line(&mut line).unwrap_or_else(|e| fatal!("cannot read, {e}")) > 0 {
358 let line_r = replace(&line, 0).unwrap_or_else(|e| fatal!("line {ln}, {e}"));
359 target.write_all(line_r.as_bytes()).unwrap_or_else(|e| fatal!("cannot write, {e}"));
360 ln += 1;
361 line.clear();
362 }
363 target.flush().unwrap_or_else(|e| fatal!("cannot write, {e}"));
364}
365
366const MAX_RECURSION: usize = 32;
367fn replace(line: &str, recursion_depth: usize) -> Result<String, String> {
368 let mut n2 = '\0';
369 let mut n1 = '\0';
370 let mut out = String::with_capacity(line.len());
371
372 let mut iterator = line.char_indices();
373 'main: while let Some((ci, c)) = iterator.next() {
374 if n1 == '$' && c == '{' {
375 out.pop();
376 if n2 == '$' {
377 out.push('{');
378 n1 = '{';
379 continue 'main;
380 }
381
382 let start = ci + 1;
383 let mut depth = 0;
384 let mut end = usize::MAX;
385 'seek_end: for (i, c) in iterator.by_ref() {
386 if c == '{' {
387 depth += 1;
388 } else if c == '}' {
389 if depth == 0 {
390 end = i;
391 break 'seek_end;
392 }
393 depth -= 1;
394 }
395 }
396 if end == usize::MAX {
397 let end = (start + 10).min(line.len());
398 return Err(format!("replace not closed at: ${{{}", &line[start..end]));
399 } else {
400 let mut var = &line[start..end];
401 let mut case = "";
402 let mut fallback = None;
403
404 let mut search_start = 0;
406 if var.starts_with('!') {
407 let mut quoted = false;
408 let mut escape_next = false;
409 for (i, c) in var.char_indices() {
410 if mem::take(&mut escape_next) {
411 continue;
412 }
413 if c == '\\' {
414 escape_next = true;
415 } else if c == '"' {
416 quoted = !quoted;
417 } else if !quoted && c == ':' {
418 search_start = i;
419 break;
420 }
421 }
422 }
423 if let Some(i) = var[search_start..].find(':') {
424 let i = search_start + i;
425 case = &var[i + 1..];
426 var = &var[..i];
427 if let Some(i) = case.find('?') {
428 fallback = Some(&case[i + 1..]);
429 case = &case[..i];
430 }
431 }
432
433 let value = if let Some(path) = var.strip_prefix('<') {
434 match std::fs::read_to_string(path) {
435 Ok(s) => Some(s),
436 Err(e) => {
437 error!("cannot read `{path}`, {e}");
438 None
439 }
440 }
441 } else if let Some(script) = var.strip_prefix('!') {
442 match sh_run(script.to_owned(), true, None) {
443 Ok(r) => Some(r),
444 Err(e) => fatal!("{e}"),
445 }
446 } else {
447 env::var(var).ok()
448 };
449
450 let value = match value {
451 Some(s) => {
452 let st = s.trim();
453 if st.is_empty() {
454 None
455 } else if st == s {
456 Some(s)
457 } else {
458 Some(st.to_owned())
459 }
460 }
461 _ => None,
462 };
463
464 if let Some(mut value) = value {
465 for case in case.split('|') {
466 value = match case {
467 "k" | "kebab" => util::clean_value(&value, false).unwrap().to_case(Case::Kebab),
468 "K" | "KEBAB" => util::clean_value(&value, false).unwrap().to_case(Case::UpperKebab),
469 "s" | "snake" => util::clean_value(&value, false).unwrap().to_case(Case::Snake),
470 "S" | "SNAKE" => util::clean_value(&value, false).unwrap().to_case(Case::UpperSnake),
471 "l" | "lower" => value.to_case(Case::Lower),
472 "U" | "UPPER" => value.to_case(Case::Upper),
473 "T" | "Title" => value.to_case(Case::Title),
474 "c" | "camel" => util::clean_value(&value, false).unwrap().to_case(Case::Camel),
475 "P" | "Pascal" => util::clean_value(&value, false).unwrap().to_case(Case::Pascal),
476 "Tr" | "Train" => util::clean_value(&value, false).unwrap().to_case(Case::Train),
477 "" => value,
478 "clean" => util::clean_value(&value, false).unwrap(),
479 "f" | "file" => sanitise_file_name::sanitise(&value),
480 unknown => return Err(format!("unknown case '{unknown}'")),
481 };
482 }
483 out.push_str(&value);
484 } else if let Some(fallback) = fallback {
485 if let Some(error) = fallback.strip_prefix('!') {
486 if error.contains('$') && recursion_depth < MAX_RECURSION {
487 return Err(replace(error, recursion_depth + 1).unwrap_or_else(|_| error.to_owned()));
488 } else {
489 return Err(error.to_owned());
490 }
491 } else if fallback.contains('$') && recursion_depth < MAX_RECURSION {
492 out.push_str(&replace(fallback, recursion_depth + 1)?);
493 } else {
494 out.push_str(fallback);
495 }
496 } else {
497 return Err(format!("${{{var}}} cannot be read or is empty"));
498 }
499 }
500 } else {
501 out.push(c);
502 }
503 n2 = n1;
504 n1 = c;
505 }
506 Ok(out)
507}
508
509const WARN_HELP: &str = "
510Print a warning message
511
512You can combine this with '.zr-rp' tool
513
514The request file:
515 source/warn.zr-warn.zr-rp
516 | ${ZR_APP}!
517
518Prints a warning with the value of ZR_APP
519";
520fn warn() {
521 help(WARN_HELP);
522 let message = fs::read_to_string(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
523 println!("zng-res::warning={message}");
524}
525
526const FAIL_HELP: &str = "
527Print an error message and fail the build
528
529The request file:
530 some/dir/disallow.zr-fail.zr-rp
531 | Don't copy ${ZR_REQUEST_DD} with a glob!
532
533Prints an error message and fails the build if copied
534";
535fn fail() {
536 help(FAIL_HELP);
537 let message = fs::read_to_string(ZR_REQUEST).unwrap_or_else(|e| fatal!("{e}"));
538 fatal!("{message}");
539}
540
541const SH_HELP: &str = r#"
542Run a bash script
543
544Script is configured using environment variables (like other tools):
545
546ZR_SOURCE_DIR — Resources directory that is being build.
547ZR_TARGET_DIR — Target directory where resources are being built to.
548ZR_CACHE_DIR — Dir to use for intermediary data for the specific request.
549ZR_WORKSPACE_DIR — Cargo workspace that contains source dir. Also the working dir.
550ZR_REQUEST — Request file that called the tool (.zr-sh).
551ZR_REQUEST_DD — Parent dir of the request file.
552ZR_TARGET — Target file implied by the request file name.
553ZR_TARGET_DD — Parent dir of the target file.
554
555ZR_FINAL — Set if the script previously printed `zng-res::on-final={args}`.
556
557In a Cargo workspace the `zng::env::about` metadata is also set:
558
559ZR_APP — package.metadata.zng.about.app or package.name
560ZR_ORG — package.metadata.zng.about.org or the first package.authors
561ZR_VERSION — package.version
562ZR_DESCRIPTION — package.description
563ZR_HOMEPAGE — package.homepage
564ZR_LICENSE — package.license
565ZR_PKG_NAME — package.name
566ZR_PKG_AUTHORS — package.authors
567ZR_CRATE_NAME — package.name in snake_case
568ZR_QUALIFIER — package.metadata.zng.about.qualifier
569
570Script can make requests to the resource builder by printing to stdout.
571Current supported requests:
572
573zng-res::warning={msg} — Prints the `{msg}` as a warning after the script exits.
574zng-res::on-final={args} — Schedule second run with `ZR_FINAL={args}`, on final pass.
575
576If the script fails the entire stderr is printed and the resource build fails. Scripts run with
577`set -e` by default.
578
579Tries to run on $ZR_SH, $PROGRAMFILES/Git/bin/bash.exe, bash, sh.
580"#;
581fn sh() {
582 help(SH_HELP);
583 let script = fs::read_to_string(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
584 sh_run(script, false, None).unwrap_or_else(|e| fatal!("{e}"));
585}
586
587fn sh_options() -> Vec<std::ffi::OsString> {
588 let mut r = vec![];
589 if let Ok(sh) = env::var("ZR_SH") {
590 if !sh.is_empty() {
591 let sh = PathBuf::from(sh);
592 if sh.exists() {
593 r.push(sh.into_os_string());
594 }
595 }
596 }
597
598 #[cfg(windows)]
599 if let Ok(pf) = env::var("PROGRAMFILES") {
600 let sh = PathBuf::from(pf).join("Git/bin/bash.exe");
601 if sh.exists() {
602 r.push(sh.into_os_string());
603 }
604 }
605 #[cfg(windows)]
606 if let Ok(c) = env::var("SYSTEMDRIVE") {
607 let sh = PathBuf::from(c).join("Program Files (x86)/Git/bin/bash.exe");
608 if sh.exists() {
609 r.push(sh.into_os_string());
610 }
611 }
612
613 r.push("bash".into());
614 r.push("sh".into());
615
616 r
617}
618pub(crate) fn sh_run(mut script: String, capture: bool, current_dir: Option<&Path>) -> io::Result<String> {
619 script.insert_str(0, "set -e\n");
620
621 for opt in sh_options() {
622 let r = sh_run_try(&opt, &script, capture, current_dir)?;
623 if let Some(r) = r {
624 return Ok(r);
625 }
626 }
627 Err(io::Error::new(
628 io::ErrorKind::NotFound,
629 "cannot find bash, tried $ZR_SH, $PROGRAMFILES/Git/bin/bash.exe, bash, sh",
630 ))
631}
632fn sh_run_try(sh: &std::ffi::OsStr, script: &str, capture: bool, current_dir: Option<&Path>) -> io::Result<Option<String>> {
633 let mut sh = Command::new(sh);
634 if let Some(d) = current_dir {
635 sh.current_dir(d);
636 }
637 sh.arg("-c").arg(script);
638 sh.stdin(std::process::Stdio::null());
639 sh.stderr(std::process::Stdio::inherit());
640 if !capture {
641 sh.stdout(std::process::Stdio::inherit());
642 }
643 match sh.output() {
644 Ok(s) => {
645 if !s.status.success() {
646 return Err(match s.status.code() {
647 Some(c) => io::Error::new(io::ErrorKind::Other, format!("script failed, exit code {c}")),
648 None => io::Error::new(io::ErrorKind::Other, "script failed"),
649 });
650 }
651 Ok(Some(String::from_utf8_lossy(&s.stdout).into_owned()))
652 }
653 Err(e) => {
654 if e.kind() == io::ErrorKind::NotFound {
655 Ok(None)
656 } else {
657 Err(e)
658 }
659 }
660 }
661}
662
663const SHF_HELP: &str = r#"
664Run a bash script on the final pass
665
666Apart from running on final this tool behaves exactly like .zr-sh
667"#;
668fn shf() {
669 help(SHF_HELP);
670 if std::env::var(ZR_FINAL).is_ok() {
671 sh();
672 } else {
673 println!("zng-res::on-final=");
674 }
675}
676
677const APK_HELP: &str = r#"
678Build an Android APK from a staging directory
679
680The expected file system layout:
681
682| apk/
683| ├── lib/
684| | └── arm64-v8a
685| | └── my-app.so
686| ├── assets/
687| | └── res
688| | └── zng-res.txt
689| ├── res/
690| | └── android-res
691| └── AndroidManifest.xml
692| my-app.zr-apk
693
694Both 'apk/' and 'my-app.zr-apk' will be replaced with the built my-app.apk
695
696Expected .zr-apk file content:
697
698| # Relative path to the staging directory. If not set uses ./apk if it exists
699| # or the parent dir .. if it is named something.apk
700| apk-dir = ./apk
701|
702| # Sign using the debug key. Note that if ZR_APK_KEYSTORE or ZR_APK_KEY_ALIAS are not
703| # set the APK is also signed using the debug key.
704| debug = true
705|
706| # Don't sign and don't zipalign the APK. This outputs an incomplete package that
707| # cannot be installed, but can be modified such as custom linking and signing.
708| raw = true
709|
710| # Don't tar assets. By default `assets/res` are packed as `assets/res.tar`
711| # for use with `android_install_res`.
712| tar-assets-res = false
713
714APK signing is configured using these environment variables:
715
716ZR_APK_KEYSTORE - path to the private .keystore file
717ZR_APK_KEYSTORE_PASS - keystore file password
718ZR_APK_KEY_ALIAS - key name in the keystore
719ZR_APK_KEY_PASS - key password
720"#;
721fn apk() {
722 help(APK_HELP);
723 if std::env::var(ZR_FINAL).is_err() {
724 println!("zng-res::on-final=");
725 return;
726 }
727
728 let mut apk_dir = String::new();
730 let mut debug = false;
731 let mut raw = false;
732 let mut tar_assets = true;
733 for line in read_lines(&path(ZR_REQUEST)) {
734 let (ln, line) = line.unwrap_or_else(|e| fatal!("error reading .zr-apk request, {e}"));
735 if let Some((key, value)) = line.split_once('=') {
736 let key = key.trim();
737 let value = value.trim();
738
739 let bool_value = || match value {
740 "true" => true,
741 "false" => false,
742 _ => {
743 error!("unexpected value, line {ln}\n {line}");
744 false
745 }
746 };
747 match key {
748 "apk-dir" => apk_dir = value.to_owned(),
749 "debug" => debug = bool_value(),
750 "raw" => raw = bool_value(),
751 "tar-assets" => tar_assets = bool_value(),
752 _ => error!("unknown key, line {ln}\n {line}"),
753 }
754 } else {
755 error!("syntax error, line {ln}\n{line}");
756 }
757 }
758 let mut keystore = PathBuf::from(env::var("ZR_APK_KEYSTORE").unwrap_or_default());
759 let mut keystore_pass = env::var("ZR_APK_KEYSTORE_PASS").unwrap_or_default();
760 let mut key_alias = env::var("ZR_APK_KEY_ALIAS").unwrap_or_default();
761 let mut key_pass = env::var("ZR_APK_KEY_PASS").unwrap_or_default();
762 if keystore.as_os_str().is_empty() || key_alias.is_empty() {
763 debug = true;
764 }
765
766 let mut apk_folder = path(ZR_TARGET_DD);
767 let output_file;
768 if apk_dir.is_empty() {
769 let apk = apk_folder.join("apk");
770 if apk.exists() {
771 apk_folder = apk;
772 output_file = path(ZR_TARGET).with_extension("apk");
773 } else if apk_folder.extension().map(|e| e.eq_ignore_ascii_case("apk")).unwrap_or(false) {
774 output_file = apk_folder.clone();
775 } else {
776 fatal!("missing ./apk")
777 }
778 } else {
779 apk_folder = apk_folder.join(apk_dir);
780 if !apk_folder.is_dir() {
781 fatal!("{} not found or not a directory", apk_folder.display());
782 }
783 output_file = path(ZR_TARGET).with_extension("apk");
784 }
785 let apk_folder = apk_folder;
786
787 let android_home = match env::var("ANDROID_HOME") {
789 Ok(h) if !h.is_empty() => h,
790 _ => fatal!("please set ANDROID_HOME to the android-sdk dir"),
791 };
792 let build_tools = Path::new(&android_home).join("build-tools/");
793 let mut best_build = None;
794 let mut best_version = semver::Version::new(0, 0, 0);
795
796 #[cfg(not(windows))]
797 const AAPT2_NAME: &str = "aapt2";
798 #[cfg(windows)]
799 const AAPT2_NAME: &str = "aapt2.exe";
800
801 for dir in fs::read_dir(build_tools).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/, {e}")) {
802 let dir = dir
803 .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/ entry, {e}"))
804 .path();
805
806 if let Some(ver) = dir
807 .file_name()
808 .and_then(|f| f.to_str())
809 .and_then(|f| semver::Version::parse(f).ok())
810 {
811 if ver > best_version && dir.join(AAPT2_NAME).exists() {
812 best_build = Some(dir);
813 best_version = ver;
814 }
815 }
816 }
817 let build_tools = match best_build {
818 Some(p) => p,
819 None => fatal!("cannot find $ANDROID_HOME/build-tools/<version>/{AAPT2_NAME}"),
820 };
821 let aapt2_path = build_tools.join(AAPT2_NAME);
822
823 let temp_dir = apk_folder.with_extension("apk.tmp");
825 let _ = fs::remove_dir_all(&temp_dir);
826 fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
827
828 let assets = apk_folder.join("assets");
830 let assets_res = assets.join("res");
831 if tar_assets && assets_res.exists() {
832 let tar_path = assets.join("res.tar");
833 let r = Command::new("tar")
834 .arg("-cf")
835 .arg(&tar_path)
836 .arg("res")
837 .current_dir(&assets)
838 .status();
839 match r {
840 Ok(s) => {
841 if !s.success() {
842 fatal!("tar failed")
843 }
844 }
845 Err(e) => fatal!("cannot run 'tar', {e}"),
846 }
847 if let Err(e) = fs::remove_dir_all(&assets_res) {
848 fatal!("failed tar-assets-res cleanup, {e}")
849 }
850 }
851
852 let compiled_res = temp_dir.join("compiled_res.zip");
854 let res = apk_folder.join("res");
855 if res.exists() {
856 let mut aapt2 = Command::new(&aapt2_path);
857 aapt2.arg("compile").arg("-o").arg(&compiled_res).arg("--dir").arg(res);
858
859 if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
860 fatal!("resources build failed");
861 }
862 }
863
864 let manifest_path = apk_folder.join("AndroidManifest.xml");
865 let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| fatal!("cannot read AndroidManifest.xml, {e}"));
866 let manifest: AndroidManifest = quick_xml::de::from_str(&manifest).unwrap_or_else(|e| fatal!("error parsing AndroidManifest.xml, {e}"));
867
868 let platforms = Path::new(&android_home).join("platforms");
870 let mut best_platform = None;
871 let mut best_version = 0;
872 for dir in fs::read_dir(platforms).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/, {e}")) {
873 let dir = dir
874 .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/ entry, {e}"))
875 .path();
876
877 if let Some(ver) = dir
878 .file_name()
879 .and_then(|f| f.to_str())
880 .and_then(|f| f.strip_prefix("android-"))
881 .and_then(|f| f.parse().ok())
882 {
883 if manifest.uses_sdk.matches(ver) && ver > best_version && dir.join("android.jar").exists() {
884 best_platform = Some(dir);
885 best_version = ver;
886 }
887 }
888 }
889 let platform = match best_platform {
890 Some(p) => p,
891 None => fatal!("cannot find $ANDROID_HOME/platforms/<version>/android.jar"),
892 };
893
894 let apk_path = temp_dir.join("output.apk");
896 let mut aapt2 = Command::new(&aapt2_path);
897 aapt2
898 .arg("link")
899 .arg("-o")
900 .arg(&apk_path)
901 .arg("--manifest")
902 .arg(manifest_path)
903 .arg("-I")
904 .arg(platform.join("android.jar"));
905 if compiled_res.exists() {
906 aapt2.arg(&compiled_res);
907 }
908 if assets.exists() {
909 aapt2.arg("-A").arg(&assets);
910 }
911 if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
912 fatal!("apk linking failed");
913 }
914
915 let aapt_path = build_tools.join("aapt");
917 for lib in glob::glob(apk_folder.join("lib/*/*.so").display().to_string().as_str()).unwrap() {
918 let lib = lib.unwrap_or_else(|e| fatal!("error searching libs, {e}"));
919
920 let lib = lib.display().to_string().replace('\\', "/");
921 let lib = &lib[lib.rfind("/lib/").unwrap() + 1..];
922
923 let mut aapt = Command::new(&aapt_path);
924 aapt.arg("add").arg(&apk_path).arg(lib).current_dir(&apk_folder);
925 if aapt.status().map(|s| !s.success()).unwrap_or(true) {
926 fatal!("apk linking failed");
927 }
928 }
929
930 let final_apk = if raw {
931 apk_path
932 } else {
933 let aligned_apk_path = temp_dir.join("output-aligned.apk");
935 let zipalign_path = build_tools.join("zipalign");
936 let mut zipalign = Command::new(zipalign_path);
937 zipalign.arg("-v").arg("4").arg(apk_path).arg(&aligned_apk_path);
938 if zipalign.status().map(|s| !s.success()).unwrap_or(true) {
939 fatal!("zipalign failed");
940 }
941
942 let signed_apk_path = temp_dir.join("output-signed.apk");
944 if debug {
945 let dirs = directories::BaseDirs::new().unwrap_or_else(|| fatal!("cannot fine $HOME"));
946 keystore = dirs.home_dir().join(".android/debug.keystore");
947 keystore_pass = "android".to_owned();
948 key_alias = "androiddebugkey".to_owned();
949 key_pass = "android".to_owned();
950 if !keystore.exists() {
951 let _ = fs::create_dir_all(keystore.parent().unwrap());
953 let keytool_path = Path::new(&env::var("JAVA_HOME").expect("please set JAVA_HOME")).join("bin/keytool");
954 let mut keytool = Command::new(&keytool_path);
955 keytool
956 .arg("-genkey")
957 .arg("-v")
958 .arg("-keystore")
959 .arg(&keystore)
960 .arg("-storepass")
961 .arg(&keystore_pass)
962 .arg("-alias")
963 .arg(&key_alias)
964 .arg("-keypass")
965 .arg(&key_pass)
966 .arg("-keyalg")
967 .arg("RSA")
968 .arg("-keysize")
969 .arg("2048")
970 .arg("-validity")
971 .arg("10000")
972 .arg("-dname")
973 .arg("CN=Android Debug,O=Android,C=US")
974 .arg("-storetype")
975 .arg("pkcs12");
976
977 match keytool.status() {
978 Ok(s) => {
979 if !s.success() {
980 fatal!("keytool failed generating debug keys");
981 }
982 }
983 Err(e) => fatal!("cannot run '{}', {e}", keytool_path.display()),
984 }
985 }
986 }
987
988 #[cfg(not(windows))]
989 const APKSIGNER_NAME: &str = "apksigner";
990 #[cfg(windows)]
991 const APKSIGNER_NAME: &str = "apksigner.bat";
992
993 let apksigner_path = build_tools.join(APKSIGNER_NAME);
994 let mut apksigner = Command::new(&apksigner_path);
995 apksigner
996 .arg("sign")
997 .arg("--ks")
998 .arg(keystore)
999 .arg("--ks-pass")
1000 .arg(format!("pass:{keystore_pass}"))
1001 .arg("--ks-key-alias")
1002 .arg(key_alias)
1003 .arg("--key-pass")
1004 .arg(format!("pass:{key_pass}"))
1005 .arg("--out")
1006 .arg(&signed_apk_path)
1007 .arg(&aligned_apk_path);
1008
1009 match apksigner.status() {
1010 Ok(s) => {
1011 if !s.success() {
1012 fatal!("apksigner failed")
1013 }
1014 }
1015 Err(e) => fatal!("cannot run '{}', {e}", apksigner_path.display()),
1016 }
1017 signed_apk_path
1018 };
1019
1020 fs::remove_dir_all(&apk_folder).unwrap_or_else(|e| fatal!("apk folder cleanup failed, {e}"));
1022 fs::rename(final_apk, output_file).unwrap_or_else(|e| fatal!("cannot copy built apk to final place, {e}"));
1023 fs::remove_dir_all(&temp_dir).unwrap_or_else(|e| fatal!("temp dir cleanup failed, {e}"));
1024 let _ = fs::remove_file(path(ZR_TARGET));
1025}
1026#[derive(serde::Deserialize)]
1027#[serde(rename = "manifest")]
1028struct AndroidManifest {
1029 #[serde(rename = "uses-sdk")]
1030 #[serde(default)]
1031 pub uses_sdk: AndroidSdk,
1032}
1033#[derive(Default, serde::Deserialize)]
1034#[serde(rename = "uses-sdk")]
1035struct AndroidSdk {
1036 #[serde(rename(serialize = "android:minSdkVersion"))]
1037 pub min_sdk_version: Option<u32>,
1038 #[serde(rename(serialize = "android:targetSdkVersion"))]
1039 pub target_sdk_version: Option<u32>,
1040 #[serde(rename(serialize = "android:maxSdkVersion"))]
1041 pub max_sdk_version: Option<u32>,
1042}
1043impl AndroidSdk {
1044 pub fn matches(&self, version: u32) -> bool {
1045 if let Some(v) = self.target_sdk_version {
1046 return v == version;
1047 }
1048 if let Some(m) = self.min_sdk_version {
1049 if version < m {
1050 return false;
1051 }
1052 }
1053 if let Some(m) = self.max_sdk_version {
1054 if version > m {
1055 return false;
1056 }
1057 }
1058 true
1059 }
1060}
1061
1062fn read_line(path: &Path, expected: &str) -> io::Result<String> {
1063 match read_lines(path).next() {
1064 Some(r) => r.map(|(_, l)| l),
1065 None => Err(io::Error::new(
1066 io::ErrorKind::InvalidInput,
1067 format!("expected {expected} in tool file content"),
1068 )),
1069 }
1070}
1071
1072fn read_lines(path: &Path) -> impl Iterator<Item = io::Result<(usize, String)>> {
1073 enum State {
1074 Open(io::Result<fs::File>),
1075 Lines(usize, io::Lines<io::BufReader<fs::File>>),
1076 End,
1077 }
1078 let mut state = State::Open(fs::File::open(path));
1080 std::iter::from_fn(move || {
1081 loop {
1082 match std::mem::replace(&mut state, State::End) {
1083 State::Lines(count, mut lines) => {
1084 if let Some(l) = lines.next() {
1085 match l {
1086 Ok(l) => {
1088 state = State::Lines(count + 1, lines);
1089 let test = l.trim();
1090 if !test.is_empty() && !test.starts_with('#') {
1091 return Some(Ok((count, l)));
1092 }
1093 }
1094 Err(e) => {
1096 return Some(Err(e));
1097 }
1098 }
1099 }
1100 }
1101 State::Open(r) => match r {
1102 Ok(f) => state = State::Lines(1, io::BufReader::new(f).lines()),
1104 Err(e) => return Some(Err(e)),
1106 },
1107 State::End => return None,
1109 }
1110 }
1111 })
1112}
1113
1114fn read_path(request_file: &Path) -> io::Result<PathBuf> {
1115 read_line(request_file, "path").map(PathBuf::from)
1116}
1117
1118fn copy_dir_all(from: &Path, to: &Path, trace: bool) {
1119 for entry in walkdir::WalkDir::new(from).min_depth(1).max_depth(1).sort_by_file_name() {
1120 let entry = entry.unwrap_or_else(|e| fatal!("cannot walkdir entry `{}`, {e}", from.display()));
1121 let from = entry.path();
1122 let to = to.join(entry.file_name());
1123 if entry.file_type().is_dir() {
1124 fs::create_dir(&to).unwrap_or_else(|e| {
1125 if e.kind() != io::ErrorKind::AlreadyExists {
1126 fatal!("cannot create_dir `{}`, {e}", to.display())
1127 }
1128 });
1129 if trace {
1130 println!("{}", display_path(&to));
1131 }
1132 copy_dir_all(from, &to, trace);
1133 } else if entry.file_type().is_file() {
1134 fs::copy(from, &to).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", from.display(), to.display()));
1135 if trace {
1136 println!("{}", display_path(&to));
1137 }
1138 } else if entry.file_type().is_symlink() {
1139 symlink_warn(entry.path())
1140 }
1141 }
1142}
1143
1144pub(crate) fn symlink_warn(path: &Path) {
1145 warn!("symlink ignored in `{}`, use zr-tools to 'link'", path.display());
1146}
1147
1148pub const ENV_TOOL: &str = "ZNG_RES_TOOL";
1149
1150macro_rules! built_in {
1151 ($($tool:tt,)+) => {
1152 pub static BUILT_INS: &[&str] = &[
1153 $(stringify!($tool),)+
1154 ];
1155 static BUILT_IN_FNS: &[fn()] = &[
1156 $($tool,)+
1157 ];
1158 };
1159}
1160built_in! {
1161 copy,
1162 glob,
1163 rp,
1164 sh,
1165 shf,
1166 warn,
1167 fail,
1168 apk,
1169}
1170
1171pub fn run() {
1172 if let Ok(tool) = env::var(ENV_TOOL) {
1173 if let Some(i) = BUILT_INS.iter().position(|n| *n == tool.as_str()) {
1174 (BUILT_IN_FNS[i])();
1175 std::process::exit(0);
1176 } else {
1177 fatal!("`tool` is not a built-in tool");
1178 }
1179 }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185
1186 #[test]
1187 fn replace_tests() {
1188 unsafe {
1189 std::env::set_var("ZR_RP_TEST", "test value");
1193 }
1194
1195 assert_eq!("", replace("", 0).unwrap());
1196 assert_eq!("normal text", replace("normal text", 0).unwrap());
1197 assert_eq!("escaped ${NOT}", replace("escaped $${NOT}", 0).unwrap());
1198 assert_eq!("replace 'test value'", replace("replace '${ZR_RP_TEST}'", 0).unwrap());
1199 assert_eq!("${} cannot be read or is empty", replace("empty '${}'", 0).unwrap_err()); assert_eq!(
1201 "${ZR_RP_TEST_NOT_SET} cannot be read or is empty",
1202 replace("not set '${ZR_RP_TEST_NOT_SET}'", 0).unwrap_err()
1203 );
1204 assert_eq!(
1205 "not set 'fallback!'",
1206 replace("not set '${ZR_RP_TEST_NOT_SET:?fallback!}'", 0).unwrap()
1207 );
1208 assert_eq!(
1209 "not set 'nested 'test value'.'",
1210 replace("not set '${ZR_RP_TEST_NOT_SET:?nested '${ZR_RP_TEST}'.}'", 0).unwrap()
1211 );
1212 assert_eq!("test value", replace("${ZR_RP_TEST_NOT_SET:?${ZR_RP_TEST}}", 0).unwrap());
1213 assert_eq!(
1214 "curly test value",
1215 replace("curly ${ZR_RP_TEST:?{not {what} {is} {going {on {here {:?}}}}}}", 0).unwrap()
1216 );
1217
1218 assert_eq!("replace not closed at: ${MISSING", replace("${MISSING", 0).unwrap_err());
1219 assert_eq!("replace not closed at: ${MIS", replace("${MIS", 0).unwrap_err());
1220 assert_eq!("replace not closed at: ${MIS:?{", replace("${MIS:?{", 0).unwrap_err());
1221 assert_eq!("replace not closed at: ${MIS:?{}", replace("${MIS:?{}", 0).unwrap_err());
1222
1223 assert_eq!("TEST VALUE", replace("${ZR_RP_TEST:U}", 0).unwrap());
1224 assert_eq!("TEST-VALUE", replace("${ZR_RP_TEST:K}", 0).unwrap());
1225 assert_eq!("TEST_VALUE", replace("${ZR_RP_TEST:S}", 0).unwrap());
1226 assert_eq!("testValue", replace("${ZR_RP_TEST:c}", 0).unwrap());
1227 }
1228
1229 #[test]
1230 fn replace_cmd_case() {
1231 assert_eq!("cmd HELLO:?WORLD", replace("cmd ${!printf \"hello:?world\":U}", 0).unwrap(),)
1232 }
1233}