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