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 && !sh.is_empty()
591 {
592 let sh = PathBuf::from(sh);
593 if sh.exists() {
594 r.push(sh.into_os_string());
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 let r = if capture {
641 sh.output().map(|o| (o.status, String::from_utf8_lossy(&o.stdout).into_owned()))
642 } else {
643 sh.stdout(std::process::Stdio::inherit());
644 sh.status().map(|s| (s, String::new()))
645 };
646 match r {
647 Ok((s, o)) => {
648 if !s.success() {
649 return Err(match s.code() {
650 Some(c) => io::Error::other(format!("script failed, exit code {c}")),
651 None => io::Error::other("script failed"),
652 });
653 }
654 Ok(Some(o))
655 }
656 Err(e) => {
657 if e.kind() == io::ErrorKind::NotFound {
658 Ok(None)
659 } else {
660 Err(e)
661 }
662 }
663 }
664}
665
666const SHF_HELP: &str = r#"
667Run a bash script on the final pass
668
669Apart from running on final this tool behaves exactly like .zr-sh
670"#;
671fn shf() {
672 help(SHF_HELP);
673 if std::env::var(ZR_FINAL).is_ok() {
674 sh();
675 } else {
676 println!("zng-res::on-final=");
677 }
678}
679
680const APK_HELP: &str = r#"
681Build an Android APK from a staging directory
682
683The expected file system layout:
684
685| apk/
686| ├── lib/
687| | └── arm64-v8a
688| | └── my-app.so
689| ├── assets/
690| | └── res
691| | └── zng-res.txt
692| ├── res/
693| | └── android-res
694| └── AndroidManifest.xml
695| my-app.zr-apk
696
697Both 'apk/' and 'my-app.zr-apk' will be replaced with the built my-app.apk
698
699Expected .zr-apk file content:
700
701| # Relative path to the staging directory. If not set uses ./apk if it exists
702| # or the parent dir .. if it is named something.apk
703| apk-dir = ./apk
704|
705| # Sign using the debug key. Note that if ZR_APK_KEYSTORE or ZR_APK_KEY_ALIAS are not
706| # set the APK is also signed using the debug key.
707| debug = true
708|
709| # Don't sign and don't zipalign the APK. This outputs an incomplete package that
710| # cannot be installed, but can be modified such as custom linking and signing.
711| raw = true
712|
713| # Don't tar assets. By default `assets/res` are packed as `assets/res.tar`
714| # for use with `android_install_res`.
715| tar-assets-res = false
716
717APK signing is configured using these environment variables:
718
719ZR_APK_KEYSTORE - path to the private .keystore file
720ZR_APK_KEYSTORE_PASS - keystore file password
721ZR_APK_KEY_ALIAS - key name in the keystore
722ZR_APK_KEY_PASS - key password
723"#;
724fn apk() {
725 help(APK_HELP);
726 if std::env::var(ZR_FINAL).is_err() {
727 println!("zng-res::on-final=");
728 return;
729 }
730
731 let mut apk_dir = String::new();
733 let mut debug = false;
734 let mut raw = false;
735 let mut tar_assets = true;
736 for line in read_lines(&path(ZR_REQUEST)) {
737 let (ln, line) = line.unwrap_or_else(|e| fatal!("error reading .zr-apk request, {e}"));
738 if let Some((key, value)) = line.split_once('=') {
739 let key = key.trim();
740 let value = value.trim();
741
742 let bool_value = || match value {
743 "true" => true,
744 "false" => false,
745 _ => {
746 error!("unexpected value, line {ln}\n {line}");
747 false
748 }
749 };
750 match key {
751 "apk-dir" => apk_dir = value.to_owned(),
752 "debug" => debug = bool_value(),
753 "raw" => raw = bool_value(),
754 "tar-assets" => tar_assets = bool_value(),
755 _ => error!("unknown key, line {ln}\n {line}"),
756 }
757 } else {
758 error!("syntax error, line {ln}\n{line}");
759 }
760 }
761 let mut keystore = PathBuf::from(env::var("ZR_APK_KEYSTORE").unwrap_or_default());
762 let mut keystore_pass = env::var("ZR_APK_KEYSTORE_PASS").unwrap_or_default();
763 let mut key_alias = env::var("ZR_APK_KEY_ALIAS").unwrap_or_default();
764 let mut key_pass = env::var("ZR_APK_KEY_PASS").unwrap_or_default();
765 if keystore.as_os_str().is_empty() || key_alias.is_empty() {
766 debug = true;
767 }
768
769 let mut apk_folder = path(ZR_TARGET_DD);
770 let output_file;
771 if apk_dir.is_empty() {
772 let apk = apk_folder.join("apk");
773 if apk.exists() {
774 apk_folder = apk;
775 output_file = path(ZR_TARGET).with_extension("apk");
776 } else if apk_folder.extension().map(|e| e.eq_ignore_ascii_case("apk")).unwrap_or(false) {
777 output_file = apk_folder.clone();
778 } else {
779 fatal!("missing ./apk")
780 }
781 } else {
782 apk_folder = apk_folder.join(apk_dir);
783 if !apk_folder.is_dir() {
784 fatal!("{} not found or not a directory", apk_folder.display());
785 }
786 output_file = path(ZR_TARGET).with_extension("apk");
787 }
788 let apk_folder = apk_folder;
789
790 let android_home = match env::var("ANDROID_HOME") {
792 Ok(h) if !h.is_empty() => h,
793 _ => fatal!("please set ANDROID_HOME to the android-sdk dir"),
794 };
795 let build_tools = Path::new(&android_home).join("build-tools/");
796 let mut best_build = None;
797 let mut best_version = semver::Version::new(0, 0, 0);
798
799 #[cfg(not(windows))]
800 const AAPT2_NAME: &str = "aapt2";
801 #[cfg(windows)]
802 const AAPT2_NAME: &str = "aapt2.exe";
803
804 for dir in fs::read_dir(build_tools).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/, {e}")) {
805 let dir = dir
806 .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/ entry, {e}"))
807 .path();
808
809 if let Some(ver) = dir
810 .file_name()
811 .and_then(|f| f.to_str())
812 .and_then(|f| semver::Version::parse(f).ok())
813 && ver > best_version
814 && dir.join(AAPT2_NAME).exists()
815 {
816 best_build = Some(dir);
817 best_version = ver;
818 }
819 }
820 let build_tools = match best_build {
821 Some(p) => p,
822 None => fatal!("cannot find $ANDROID_HOME/build-tools/<version>/{AAPT2_NAME}"),
823 };
824 let aapt2_path = build_tools.join(AAPT2_NAME);
825
826 let temp_dir = apk_folder.with_extension("apk.tmp");
828 let _ = fs::remove_dir_all(&temp_dir);
829 fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
830
831 let assets = apk_folder.join("assets");
833 let assets_res = assets.join("res");
834 if tar_assets && assets_res.exists() {
835 let tar_path = assets.join("res.tar");
836 let r = Command::new("tar")
837 .arg("-cf")
838 .arg(&tar_path)
839 .arg("res")
840 .current_dir(&assets)
841 .status();
842 match r {
843 Ok(s) => {
844 if !s.success() {
845 fatal!("tar failed")
846 }
847 }
848 Err(e) => fatal!("cannot run 'tar', {e}"),
849 }
850 if let Err(e) = fs::remove_dir_all(&assets_res) {
851 fatal!("failed tar-assets-res cleanup, {e}")
852 }
853 }
854
855 let compiled_res = temp_dir.join("compiled_res.zip");
857 let res = apk_folder.join("res");
858 if res.exists() {
859 let mut aapt2 = Command::new(&aapt2_path);
860 aapt2.arg("compile").arg("-o").arg(&compiled_res).arg("--dir").arg(res);
861
862 if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
863 fatal!("resources build failed");
864 }
865 }
866
867 let manifest_path = apk_folder.join("AndroidManifest.xml");
868 let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| fatal!("cannot read AndroidManifest.xml, {e}"));
869 let manifest: AndroidManifest = quick_xml::de::from_str(&manifest).unwrap_or_else(|e| fatal!("error parsing AndroidManifest.xml, {e}"));
870
871 let platforms = Path::new(&android_home).join("platforms");
873 let mut best_platform = None;
874 let mut best_version = 0;
875 for dir in fs::read_dir(platforms).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/, {e}")) {
876 let dir = dir
877 .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/ entry, {e}"))
878 .path();
879
880 if let Some(ver) = dir
881 .file_name()
882 .and_then(|f| f.to_str())
883 .and_then(|f| f.strip_prefix("android-"))
884 .and_then(|f| f.parse().ok())
885 && manifest.uses_sdk.matches(ver)
886 && ver > best_version
887 && dir.join("android.jar").exists()
888 {
889 best_platform = Some(dir);
890 best_version = ver;
891 }
892 }
893 let platform = match best_platform {
894 Some(p) => p,
895 None => fatal!("cannot find $ANDROID_HOME/platforms/<version>/android.jar"),
896 };
897
898 let apk_path = temp_dir.join("output.apk");
900 let mut aapt2 = Command::new(&aapt2_path);
901 aapt2
902 .arg("link")
903 .arg("-o")
904 .arg(&apk_path)
905 .arg("--manifest")
906 .arg(manifest_path)
907 .arg("-I")
908 .arg(platform.join("android.jar"));
909 if compiled_res.exists() {
910 aapt2.arg(&compiled_res);
911 }
912 if assets.exists() {
913 aapt2.arg("-A").arg(&assets);
914 }
915 if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
916 fatal!("apk linking failed");
917 }
918
919 let aapt_path = build_tools.join("aapt");
921 for lib in glob::glob(apk_folder.join("lib/*/*.so").display().to_string().as_str()).unwrap() {
922 let lib = lib.unwrap_or_else(|e| fatal!("error searching libs, {e}"));
923
924 let lib = lib.display().to_string().replace('\\', "/");
925 let lib = &lib[lib.rfind("/lib/").unwrap() + 1..];
926
927 let mut aapt = Command::new(&aapt_path);
928 aapt.arg("add").arg(&apk_path).arg(lib).current_dir(&apk_folder);
929 if aapt.status().map(|s| !s.success()).unwrap_or(true) {
930 fatal!("apk linking failed");
931 }
932 }
933
934 let final_apk = if raw {
935 apk_path
936 } else {
937 let aligned_apk_path = temp_dir.join("output-aligned.apk");
939 let zipalign_path = build_tools.join("zipalign");
940 let mut zipalign = Command::new(zipalign_path);
941 zipalign.arg("-v").arg("4").arg(apk_path).arg(&aligned_apk_path);
942 if zipalign.status().map(|s| !s.success()).unwrap_or(true) {
943 fatal!("zipalign failed");
944 }
945
946 let signed_apk_path = temp_dir.join("output-signed.apk");
948 if debug {
949 let dirs = directories::BaseDirs::new().unwrap_or_else(|| fatal!("cannot fine $HOME"));
950 keystore = dirs.home_dir().join(".android/debug.keystore");
951 keystore_pass = "android".to_owned();
952 key_alias = "androiddebugkey".to_owned();
953 key_pass = "android".to_owned();
954 if !keystore.exists() {
955 let _ = fs::create_dir_all(keystore.parent().unwrap());
957 let keytool_path = Path::new(&env::var("JAVA_HOME").expect("please set JAVA_HOME")).join("bin/keytool");
958 let mut keytool = Command::new(&keytool_path);
959 keytool
960 .arg("-genkey")
961 .arg("-v")
962 .arg("-keystore")
963 .arg(&keystore)
964 .arg("-storepass")
965 .arg(&keystore_pass)
966 .arg("-alias")
967 .arg(&key_alias)
968 .arg("-keypass")
969 .arg(&key_pass)
970 .arg("-keyalg")
971 .arg("RSA")
972 .arg("-keysize")
973 .arg("2048")
974 .arg("-validity")
975 .arg("10000")
976 .arg("-dname")
977 .arg("CN=Android Debug,O=Android,C=US")
978 .arg("-storetype")
979 .arg("pkcs12");
980
981 match keytool.status() {
982 Ok(s) => {
983 if !s.success() {
984 fatal!("keytool failed generating debug keys");
985 }
986 }
987 Err(e) => fatal!("cannot run '{}', {e}", keytool_path.display()),
988 }
989 }
990 }
991
992 #[cfg(not(windows))]
993 const APKSIGNER_NAME: &str = "apksigner";
994 #[cfg(windows)]
995 const APKSIGNER_NAME: &str = "apksigner.bat";
996
997 let apksigner_path = build_tools.join(APKSIGNER_NAME);
998 let mut apksigner = Command::new(&apksigner_path);
999 apksigner
1000 .arg("sign")
1001 .arg("--ks")
1002 .arg(keystore)
1003 .arg("--ks-pass")
1004 .arg(format!("pass:{keystore_pass}"))
1005 .arg("--ks-key-alias")
1006 .arg(key_alias)
1007 .arg("--key-pass")
1008 .arg(format!("pass:{key_pass}"))
1009 .arg("--out")
1010 .arg(&signed_apk_path)
1011 .arg(&aligned_apk_path);
1012
1013 match apksigner.status() {
1014 Ok(s) => {
1015 if !s.success() {
1016 fatal!("apksigner failed")
1017 }
1018 }
1019 Err(e) => fatal!("cannot run '{}', {e}", apksigner_path.display()),
1020 }
1021 signed_apk_path
1022 };
1023
1024 fs::remove_dir_all(&apk_folder).unwrap_or_else(|e| fatal!("apk folder cleanup failed, {e}"));
1026 fs::rename(final_apk, output_file).unwrap_or_else(|e| fatal!("cannot copy built apk to final place, {e}"));
1027 fs::remove_dir_all(&temp_dir).unwrap_or_else(|e| fatal!("temp dir cleanup failed, {e}"));
1028 let _ = fs::remove_file(path(ZR_TARGET));
1029}
1030#[derive(serde::Deserialize)]
1031#[serde(rename = "manifest")]
1032struct AndroidManifest {
1033 #[serde(rename = "uses-sdk")]
1034 #[serde(default)]
1035 pub uses_sdk: AndroidSdk,
1036}
1037#[derive(Default, serde::Deserialize)]
1038#[serde(rename = "uses-sdk")]
1039struct AndroidSdk {
1040 #[serde(rename(serialize = "android:minSdkVersion"))]
1041 pub min_sdk_version: Option<u32>,
1042 #[serde(rename(serialize = "android:targetSdkVersion"))]
1043 pub target_sdk_version: Option<u32>,
1044 #[serde(rename(serialize = "android:maxSdkVersion"))]
1045 pub max_sdk_version: Option<u32>,
1046}
1047impl AndroidSdk {
1048 pub fn matches(&self, version: u32) -> bool {
1049 if let Some(v) = self.target_sdk_version {
1050 return v == version;
1051 }
1052 if let Some(m) = self.min_sdk_version
1053 && version < m
1054 {
1055 return false;
1056 }
1057 if let Some(m) = self.max_sdk_version
1058 && version > m
1059 {
1060 return false;
1061 }
1062 true
1063 }
1064}
1065
1066fn read_line(path: &Path, expected: &str) -> io::Result<String> {
1067 match read_lines(path).next() {
1068 Some(r) => r.map(|(_, l)| l),
1069 None => Err(io::Error::new(
1070 io::ErrorKind::InvalidInput,
1071 format!("expected {expected} in tool file content"),
1072 )),
1073 }
1074}
1075
1076fn read_lines(path: &Path) -> impl Iterator<Item = io::Result<(usize, String)>> {
1077 enum State {
1078 Open(io::Result<fs::File>),
1079 Lines(usize, io::Lines<io::BufReader<fs::File>>),
1080 End,
1081 }
1082 let mut state = State::Open(fs::File::open(path));
1084 std::iter::from_fn(move || {
1085 loop {
1086 match std::mem::replace(&mut state, State::End) {
1087 State::Lines(count, mut lines) => {
1088 if let Some(l) = lines.next() {
1089 match l {
1090 Ok(l) => {
1092 state = State::Lines(count + 1, lines);
1093 let test = l.trim();
1094 if !test.is_empty() && !test.starts_with('#') {
1095 return Some(Ok((count, l)));
1096 }
1097 }
1098 Err(e) => {
1100 return Some(Err(e));
1101 }
1102 }
1103 }
1104 }
1105 State::Open(r) => match r {
1106 Ok(f) => state = State::Lines(1, io::BufReader::new(f).lines()),
1108 Err(e) => return Some(Err(e)),
1110 },
1111 State::End => return None,
1113 }
1114 }
1115 })
1116}
1117
1118fn read_path(request_file: &Path) -> io::Result<PathBuf> {
1119 read_line(request_file, "path").map(PathBuf::from)
1120}
1121
1122fn copy_dir_all(from: &Path, to: &Path, trace: bool) {
1123 for entry in walkdir::WalkDir::new(from).min_depth(1).max_depth(1).sort_by_file_name() {
1124 let entry = entry.unwrap_or_else(|e| fatal!("cannot walkdir entry `{}`, {e}", from.display()));
1125 let from = entry.path();
1126 let to = to.join(entry.file_name());
1127 if entry.file_type().is_dir() {
1128 fs::create_dir(&to).unwrap_or_else(|e| {
1129 if e.kind() != io::ErrorKind::AlreadyExists {
1130 fatal!("cannot create_dir `{}`, {e}", to.display())
1131 }
1132 });
1133 if trace {
1134 println!("{}", display_path(&to));
1135 }
1136 copy_dir_all(from, &to, trace);
1137 } else if entry.file_type().is_file() {
1138 fs::copy(from, &to).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", from.display(), to.display()));
1139 if trace {
1140 println!("{}", display_path(&to));
1141 }
1142 } else if entry.file_type().is_symlink() {
1143 symlink_warn(entry.path())
1144 }
1145 }
1146}
1147
1148pub(crate) fn symlink_warn(path: &Path) {
1149 warn!("symlink ignored in `{}`, use zr-tools to 'link'", path.display());
1150}
1151
1152pub const ENV_TOOL: &str = "ZNG_RES_TOOL";
1153
1154macro_rules! built_in {
1155 ($($tool:tt),+ $(,)?) => {
1156 pub static BUILT_INS: &[&str] = &[
1157 $(stringify!($tool),)+
1158 ];
1159 static BUILT_IN_FNS: &[fn()] = &[
1160 $($tool,)+
1161 ];
1162 };
1163}
1164built_in! { copy, glob, rp, sh, shf, warn, fail, apk }
1165
1166pub fn run() {
1167 if let Ok(tool) = env::var(ENV_TOOL) {
1168 if let Some(i) = BUILT_INS.iter().position(|n| *n == tool.as_str()) {
1169 (BUILT_IN_FNS[i])();
1170 std::process::exit(0);
1171 } else {
1172 fatal!("`tool` is not a built-in tool");
1173 }
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180
1181 #[test]
1182 fn replace_tests() {
1183 unsafe {
1184 std::env::set_var("ZR_RP_TEST", "test value");
1188 }
1189
1190 assert_eq!("", replace("", 0).unwrap());
1191 assert_eq!("normal text", replace("normal text", 0).unwrap());
1192 assert_eq!("escaped ${NOT}", replace("escaped $${NOT}", 0).unwrap());
1193 assert_eq!("replace 'test value'", replace("replace '${ZR_RP_TEST}'", 0).unwrap());
1194 assert_eq!("${} cannot be read or is empty", replace("empty '${}'", 0).unwrap_err()); assert_eq!(
1196 "${ZR_RP_TEST_NOT_SET} cannot be read or is empty",
1197 replace("not set '${ZR_RP_TEST_NOT_SET}'", 0).unwrap_err()
1198 );
1199 assert_eq!(
1200 "not set 'fallback!'",
1201 replace("not set '${ZR_RP_TEST_NOT_SET:?fallback!}'", 0).unwrap()
1202 );
1203 assert_eq!(
1204 "not set 'nested 'test value'.'",
1205 replace("not set '${ZR_RP_TEST_NOT_SET:?nested '${ZR_RP_TEST}'.}'", 0).unwrap()
1206 );
1207 assert_eq!("test value", replace("${ZR_RP_TEST_NOT_SET:?${ZR_RP_TEST}}", 0).unwrap());
1208 assert_eq!(
1209 "curly test value",
1210 replace("curly ${ZR_RP_TEST:?{not {what} {is} {going {on {here {:?}}}}}}", 0).unwrap()
1211 );
1212
1213 assert_eq!("replace not closed at: ${MISSING", replace("${MISSING", 0).unwrap_err());
1214 assert_eq!("replace not closed at: ${MIS", replace("${MIS", 0).unwrap_err());
1215 assert_eq!("replace not closed at: ${MIS:?{", replace("${MIS:?{", 0).unwrap_err());
1216 assert_eq!("replace not closed at: ${MIS:?{}", replace("${MIS:?{}", 0).unwrap_err());
1217
1218 assert_eq!("TEST VALUE", replace("${ZR_RP_TEST:U}", 0).unwrap());
1219 assert_eq!("TEST-VALUE", replace("${ZR_RP_TEST:K}", 0).unwrap());
1220 assert_eq!("TEST_VALUE", replace("${ZR_RP_TEST:S}", 0).unwrap());
1221 assert_eq!("testValue", replace("${ZR_RP_TEST:c}", 0).unwrap());
1222 }
1223
1224 #[test]
1225 fn replace_cmd_case() {
1226 assert_eq!("cmd HELLO:?WORLD", replace("cmd ${!printf \"hello:?world\":U}", 0).unwrap(),)
1227 }
1228}