diff --git a/Screenshot/Screenshot.png b/Screenshot/Screenshot.png deleted file mode 100644 index dd198c9..0000000 Binary files a/Screenshot/Screenshot.png and /dev/null differ diff --git a/Screenshot/Screenshot1.png b/Screenshot/Screenshot1.png deleted file mode 100644 index 8935b74..0000000 Binary files a/Screenshot/Screenshot1.png and /dev/null differ diff --git a/Screenshot/Screenshot2.png b/Screenshot/Screenshot2.png deleted file mode 100644 index ae42be5..0000000 Binary files a/Screenshot/Screenshot2.png and /dev/null differ diff --git a/Screenshot/Screenshot3.png b/Screenshot/Screenshot3.png deleted file mode 100644 index c28254c..0000000 Binary files a/Screenshot/Screenshot3.png and /dev/null differ diff --git a/Screenshot/Screenshot4.png b/Screenshot/Screenshot4.png deleted file mode 100644 index d05c6e1..0000000 Binary files a/Screenshot/Screenshot4.png and /dev/null differ diff --git a/Screenshot/Screenshot5.png b/Screenshot/Screenshot5.png deleted file mode 100644 index dbd58c1..0000000 Binary files a/Screenshot/Screenshot5.png and /dev/null differ diff --git a/Screenshot/Screenshot6.png b/Screenshot/Screenshot6.png deleted file mode 100644 index 7765fe7..0000000 Binary files a/Screenshot/Screenshot6.png and /dev/null differ diff --git a/VMware Player Fix/README.md b/VMware Player Fix/README.md deleted file mode 100644 index 30081dc..0000000 --- a/VMware Player Fix/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# VMware Player Fix -This fix will work for Linux Mint 22 and Ubuntu 24.04. This works as of 7/26/24. -## Download -- Create a broadcom account here: https://profile.broadcom.com/web/registration -- Download the latest version of VMware Player here: https://support.broadcom.com/group/ecx/productdownloads?subfamily=VMware+Workstation+Pro - -## What does this script do? -- This script will patch the **vmmon** and **vmnet** VMware modules required to run VMware Player. - -## How do I run it? -1. Download **VMware Player** and **vmware-fix.sh** to ~/Downloads -2. Open a **Terminal** from ~/Downloads: - 1. Type: `chmod u+x vmware-fix.sh` - 2. Type: `./vmware-fix.sh` -4. Once the script is complete, fire up **VMware Player** (SUPER->Administrator->VMware Player) diff --git a/VMware Player Fix/vmware-fix.sh b/VMware Player Fix/vmware-fix.sh deleted file mode 100644 index b1a1770..0000000 --- a/VMware Player Fix/vmware-fix.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -printf '=====================================\n' -printf 'The Linux IT Guy - Linux Mint Scripts\n' -printf 'VMware Player Fix\n' -printf '=====================================\n' - -DOWNLOADS="${HOME}/Downloads" -MODULE_VERSION="workstation-17.5.0" -MODULE_URL="https://github.com/mkubecek/vmware-host-modules/archive/${MODULE_VERSION}.tar.gz" -MODULE_ARCHIVE="${DOWNLOADS}/${MODULE_VERSION}.tar.gz" -MODULE_DIR="${DOWNLOADS}/vmware-host-modules-${MODULE_VERSION}" - -if [[ ! -d "$DOWNLOADS" ]]; then - echo "Expected ${DOWNLOADS} to exist and contain the VMware Player bundle." >&2 - exit 1 -fi - -shopt -s nullglob -bundles=("${DOWNLOADS}"/VMware-*.bundle) -if [[ ${#bundles[@]} -ne 1 ]]; then - echo "Place exactly one VMware-*.bundle file in ${DOWNLOADS} and rerun this script." >&2 - exit 1 -fi - -bundle=${bundles[0]} -chmod u+x "$bundle" -sudo "$bundle" - -wget -O "$MODULE_ARCHIVE" "$MODULE_URL" -tar -xzf "$MODULE_ARCHIVE" -C "$DOWNLOADS" - -if [[ ! -d "$MODULE_DIR/vmmon-only" || ! -d "$MODULE_DIR/vmnet-only" ]]; then - echo "VMware host modules archive did not contain the expected directories." >&2 - exit 1 -fi - -tar -cf "${MODULE_DIR}/vmmon.tar" -C "$MODULE_DIR" vmmon-only -tar -cf "${MODULE_DIR}/vmnet.tar" -C "$MODULE_DIR" vmnet-only - -sudo cp -v "${MODULE_DIR}/vmmon.tar" "${MODULE_DIR}/vmnet.tar" /usr/lib/vmware/modules/source/ -sudo vmware-modconfig --console --install-all - -printf '=====================================\n' -printf 'The Linux IT Guy - Linux Mint Scripts\n' -printf 'VMware Player Fix - Complete\n' -printf 'Fire up Super->Administration->VMware Player to complete the installation.\n' -printf '=====================================\n' diff --git a/apps_config.csv b/apps_config.csv index f1986ab..57d3d24 100644 --- a/apps_config.csv +++ b/apps_config.csv @@ -1,5 +1,6 @@ Category,Label,Package Name,Flatpak ID,Exec Name,Notes Browsers,Brave Browser,,com.brave.Browser,brave, +Browsers,Brave Origin,brave-origin,,brave-origin,Light Privacy-focused browser Browsers,Google Chrome,,com.google.Chrome,google-chrome, Browsers,Firefox,firefox,,firefox, Browsers,Microsoft Edge,,com.microsoft.Edge,microsoft-edge, diff --git a/assets/keys/brave-install.sh.asc b/assets/keys/brave-install.sh.asc new file mode 100644 index 0000000..fa0cf99 --- /dev/null +++ b/assets/keys/brave-install.sh.asc @@ -0,0 +1,31 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGfPDCoBEACouDRqDFEP/AbVvlNktBGPa8TzeDWoi0ybIZRGHMhHb/JAHKd7 +ziD44gvjCgrq1O+w1I70VZA4JwJelWY9rmlIUwTTZwmV2gBpIeZ1F6+dUU7BRWSg +Lnc5uhfXFTNe81aY81Ne4WbskUSRB1Go13m/mHZV7vJdwcqwcxmZGqhWPbvVInes +Y6Bx9oe/sHs2dtVKD74JGtdLWYF6VzTMqyx0tDv8Dgkjr5iO1XnZcH5vl0/3Rc/x +TlUKw265yjV9SQcd+l0mTcUFlwCGvjGuNa/Z4IwkR2Q19IF94B8/YbYXUk5B/RET +JtZcW2xt8lsUTUdGAUOr0Cj3Kt01xcj+zWBUVs6gdgxdWTHAB6MVUx8BG5ScR+r8 +77/Udx6jkiG4zOAC2kR99pGFkLFwaIyqi9aLufPcROwmRcCZ2wXpGPGgsDPbWwh9 +KgMXidbcTD2j4H3SSr9cJfITB9Vf3Jh8YJiMbjHq0kI5LxqLL01H58FKa2Hsg8vQ +60BxRqPiQNKlPp9RQa3kEwC8MB7YdZ8a3/Bfsk9sKHQXlRhdgwtRsdvVFlHHWoAW +H6gOGfqwSWLOVrPXk/YCEvfF3MDk07eH3Bp4lfgtIum9bPzPf72bWE9LZyvgys/X +rTuMnDt/y0GFeUjhdlkXout2UFIPLWWsMw2qdRsfCpdeIy1A4CFABjVXGQARAQAB +tFJPbmUgTGluZSBJbnN0YWxsZXIgZm9yIEJyYXZlIChPbmUgTGluZSBJbnN0YWxs +ZXIgZm9yIEJyYXZlKSA8aW5zdGFsbC1zaEBicmF2ZS5jb20+iQJ+BBMBCABoAhsD +BQkSzAMABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAFiEE0WFmByys3yyUKcvx +G/QeN9A59pEFAmfPDC0mFIAAAAAADwAObGFiZWxAYnJhdmUuY29taW5zdGFsbF9z +aF9tcmsACgkQG/QeN9A59pHQ0A/8DnJIl56ld9Xgy0rxoyLaC2T+5dSsPAqKCAVX +jqwumxu2iukwXjzoI/yZf+oNEBpy1NtyuOb7AKmijzK1XIFYfkmYAlFnezGGWVjo +coE9vNoYZxsxHdc8iJx5NoW7BeQw/oOCr6XAj1KLdSomeyZfI7NOpTxy2apGYDSq +buaBWbZIasNSSh1asQwn7oW0IErgEBzz2Lag9lo5mt4qf0BLZ2vTs5q/R1pqQXXc +DJSM7/LOEOJfeHPjlvuIix9oTP+hqkGR770ziOA9TxZKPeJq1LEvOreUmBzz1EKI +u3LGhIKtsR9gqDP27PX7sNNT4E/0kqhTzWFg+eEk9tMPz/V1UKWDxaYSklt7R7rF +UVIp6ZLT7r8ROnTx3pPNWJjOJyRmTJ3mdRYmLoPcMKjW3JF2gJuaX+OEzFW8dagK +01Rv8epOAxxyCKBCFEJ2iTfIZn3YLwI7ItU45O4xmruHhhOPp7fe59pQDK9JMt04 +Cir/TsImNaWDdTBUv0Cx4fxu38w3by0tQZz3CVJ/oepC/fwfz8IQ36IOIUrLBrzg +T5lmUUehgRnLXsJ167TFUVHDiwSqAT0eEw2gz36CEa1xWroCQ5ZEJuYCqgi+Vn64 +SXuNvb3Q0vqXtiqy24wjy/AX4kMgbxAQpro9pLKyEVD9ygptcNFHYJI0IStuLDQA +FBMz8IY= +=oWX1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/assets/icons/audacity.png b/assets/logos/audacity.png similarity index 100% rename from assets/icons/audacity.png rename to assets/logos/audacity.png diff --git a/assets/icons/bottles.png b/assets/logos/bottles.png similarity index 100% rename from assets/icons/bottles.png rename to assets/logos/bottles.png diff --git a/assets/icons/boxes.png b/assets/logos/boxes.png similarity index 100% rename from assets/icons/boxes.png rename to assets/logos/boxes.png diff --git a/assets/logos/brave-origin.svg b/assets/logos/brave-origin.svg new file mode 100644 index 0000000..fedebaf --- /dev/null +++ b/assets/logos/brave-origin.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/brave.png b/assets/logos/brave.png similarity index 100% rename from assets/icons/brave.png rename to assets/logos/brave.png diff --git a/assets/icons/discord.png b/assets/logos/discord.png similarity index 100% rename from assets/icons/discord.png rename to assets/logos/discord.png diff --git a/assets/icons/firefox.png b/assets/logos/firefox.png similarity index 100% rename from assets/icons/firefox.png rename to assets/logos/firefox.png diff --git a/assets/icons/gimp.png b/assets/logos/gimp.png similarity index 100% rename from assets/icons/gimp.png rename to assets/logos/gimp.png diff --git a/assets/icons/google-chrome.png b/assets/logos/google-chrome.png similarity index 100% rename from assets/icons/google-chrome.png rename to assets/logos/google-chrome.png diff --git a/assets/icons/localsend.png b/assets/logos/localsend.png similarity index 100% rename from assets/icons/localsend.png rename to assets/logos/localsend.png diff --git a/assets/icons/lutris.png b/assets/logos/lutris.png similarity index 100% rename from assets/icons/lutris.png rename to assets/logos/lutris.png diff --git a/assets/icons/microsoft-edge.png b/assets/logos/microsoft-edge.png similarity index 100% rename from assets/icons/microsoft-edge.png rename to assets/logos/microsoft-edge.png diff --git a/assets/icons/obs-studio.png b/assets/logos/obs-studio.png similarity index 100% rename from assets/icons/obs-studio.png rename to assets/logos/obs-studio.png diff --git a/assets/icons/onlyoffice.png b/assets/logos/onlyoffice.png similarity index 100% rename from assets/icons/onlyoffice.png rename to assets/logos/onlyoffice.png diff --git a/assets/icons/opera.png b/assets/logos/opera.png similarity index 100% rename from assets/icons/opera.png rename to assets/logos/opera.png diff --git a/assets/icons/protonup-qt.png b/assets/logos/protonup-qt.png similarity index 100% rename from assets/icons/protonup-qt.png rename to assets/logos/protonup-qt.png diff --git a/assets/icons/pycharm-community.png b/assets/logos/pycharm-community.png similarity index 100% rename from assets/icons/pycharm-community.png rename to assets/logos/pycharm-community.png diff --git a/assets/icons/signal.png b/assets/logos/signal.png similarity index 100% rename from assets/icons/signal.png rename to assets/logos/signal.png diff --git a/assets/icons/slack.png b/assets/logos/slack.png similarity index 100% rename from assets/icons/slack.png rename to assets/logos/slack.png diff --git a/assets/icons/steam.png b/assets/logos/steam.png similarity index 100% rename from assets/icons/steam.png rename to assets/logos/steam.png diff --git a/assets/icons/thunderbird.png b/assets/logos/thunderbird.png similarity index 100% rename from assets/icons/thunderbird.png rename to assets/logos/thunderbird.png diff --git a/assets/icons/visual-studio-code.png b/assets/logos/visual-studio-code.png similarity index 100% rename from assets/icons/visual-studio-code.png rename to assets/logos/visual-studio-code.png diff --git a/assets/icons/vivaldi.png b/assets/logos/vivaldi.png similarity index 100% rename from assets/icons/vivaldi.png rename to assets/logos/vivaldi.png diff --git a/assets/icons/vlc.png b/assets/logos/vlc.png similarity index 100% rename from assets/icons/vlc.png rename to assets/logos/vlc.png diff --git a/assets/icons/zen.png b/assets/logos/zen.png similarity index 100% rename from assets/icons/zen.png rename to assets/logos/zen.png diff --git a/main.sh b/main.sh index 9bd3771..98df1fd 100644 --- a/main.sh +++ b/main.sh @@ -212,9 +212,88 @@ ensure_flatpak() { fi } +brave_origin_is_installed() { + local candidate + while IFS= read -r candidate; do + require_package_name "$candidate" + if native_installed "$candidate"; then + return 0 + fi + done < <(brave_origin_package_candidates "$PACKAGE_MANAGER") + return 1 +} + +# Download the pinned Brave installer, GPG-verify it, then run that file with +# FLAVOR=origin. Never curl|sh and never eval. +install_brave_origin() { + local tmp key_file wrap_dir + + if brave_origin_is_installed; then + echo "brave-origin is already installed. Skipping installation." + return 0 + fi + + if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to download the Brave installer." >&2 + exit 1 + fi + if ! command -v gpg >/dev/null 2>&1; then + echo "gpg is required to verify the Brave installer." >&2 + exit 1 + fi + + key_file="${SCRIPT_DIR}/assets/keys/brave-install.sh.asc" + if [[ ! -f "$key_file" ]]; then + echo "Brave installer signing key was not found at $key_file." >&2 + exit 1 + fi + + tmp=$(mktemp -d) || exit 1 + chmod 700 "$tmp" + wrap_dir="$tmp/sudo-wrap" + + if ! download_pinned_brave_file "$BRAVE_INSTALL_SH_URL" "$tmp/install.sh"; then + rm -rf "$tmp" + exit 1 + fi + if ! download_pinned_brave_file "$BRAVE_INSTALL_SH_ASC_URL" "$tmp/install.sh.asc"; then + rm -rf "$tmp" + exit 1 + fi + + echo "Verifying Brave installer signature..." + if ! verify_brave_install_script "$tmp/install.sh" "$tmp/install.sh.asc" "$key_file"; then + rm -rf "$tmp" + echo "Refusing to run unverified Brave installer." >&2 + exit 1 + fi + + chmod 700 "$tmp/install.sh" + if ! install_noninteractive_sudo_wrapper "$wrap_dir"; then + rm -rf "$tmp" + exit 1 + fi + + echo "Running verified Brave Origin installer..." + # Execute the downloaded file. Do not pipe curl to sh and do not eval. + if ! PATH="$wrap_dir:$PATH" FLAVOR=origin CHANNEL=release "$tmp/install.sh"; then + rm -rf "$tmp" + echo "Brave Origin installer failed." >&2 + exit 1 + fi + + rm -rf "$tmp" +} + install_native() { local package=$1 require_package_name "$package" + + if [[ "$package" == "brave-origin" ]]; then + install_brave_origin + return + fi + if native_installed "$package"; then echo "$package is already installed. Skipping installation." return 0 @@ -240,7 +319,7 @@ install_native() { esac } -remove_native() { +remove_native_package() { local package=$1 require_package_name "$package" if ! native_installed "$package"; then @@ -261,6 +340,28 @@ remove_native() { esac } +remove_native() { + local package=$1 + require_package_name "$package" + + if [[ "$package" == "brave-origin" ]]; then + local candidate removed=0 + while IFS= read -r candidate; do + require_package_name "$candidate" + if native_installed "$candidate"; then + remove_native_package "$candidate" + removed=1 + fi + done < <(brave_origin_package_candidates "$PACKAGE_MANAGER") + if [[ "$removed" -eq 0 ]]; then + echo "brave-origin is not installed. Skipping removal." + fi + return 0 + fi + + remove_native_package "$package" +} + install_flatpak_app() { local app_id=$1 require_flatpak_id "$app_id" diff --git a/src/catalog.rs b/src/catalog.rs index 0647b8e..b78f9b7 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -557,6 +557,48 @@ mod tests { assert!(!load.apps.is_empty()); } + #[test] + fn shipped_catalog_has_brave_origin_native_and_keeps_flatpak_brave() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let load = load_apps(&root); + assert!(load.error.is_none(), "{:?}", load.error); + assert!(load.warnings.is_empty(), "{:?}", load.warnings); + + let origin = load + .apps + .iter() + .find(|app| app.label == "Brave Origin") + .expect("Brave Origin catalog row"); + assert_eq!(origin.category, "Browsers"); + assert_eq!(origin.package_name, "brave-origin"); + assert!(origin.flatpak_id.is_empty()); + assert_eq!(origin.exec_name, "brave-origin"); + assert_eq!(origin.source_label(), "native"); + + let command = origin.try_command(&root, "install").unwrap(); + assert_eq!(command[0], "bash"); + assert!(command[1].ends_with("main.sh")); + assert!(command.contains(&"--package".to_owned())); + assert!(command.contains(&"brave-origin".to_owned())); + assert!(command.contains(&"--exec".to_owned())); + assert!(!command.contains(&"--flatpak".to_owned())); + assert_eq!(command.last().map(String::as_str), Some("install")); + + let remove = origin.try_command(&root, "remove").unwrap(); + assert_eq!(remove.last().map(String::as_str), Some("remove")); + assert!(remove.contains(&"--package".to_owned())); + assert!(!remove.contains(&"--flatpak".to_owned())); + + let flatpak_brave = load + .apps + .iter() + .find(|app| app.label == "Brave Browser") + .expect("Brave Browser Flatpak catalog row"); + assert!(flatpak_brave.package_name.is_empty()); + assert_eq!(flatpak_brave.flatpak_id, "com.brave.Browser"); + assert_eq!(flatpak_brave.exec_name, "brave"); + } + fn empty_dir(label: &str) -> PathBuf { let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( diff --git a/src/logos.rs b/src/logos.rs new file mode 100644 index 0000000..1631e13 --- /dev/null +++ b/src/logos.rs @@ -0,0 +1,227 @@ +//! Logos compiled into the binary. +//! +//! Distro chips and the Brave Origin tile use SVG markup via `include_str!`. +//! Other app tiles use Chris's original PNGs via `include_bytes!`. Nothing +//! under `assets/` is read from disk at runtime. + +use eframe::egui::ColorImage; + +/// Distro SVG markup keyed the same way the header chips look them up. +pub const DISTRO_SVGS: &[(&str, &str)] = &[ + ("arch", include_str!("../assets/distros/arch.svg")), + ("debian", include_str!("../assets/distros/debian.svg")), + ("fedora", include_str!("../assets/distros/fedora.svg")), +]; + +/// Chris's Brave Origin tile: white outlined lion on a dark rounded square. +/// Do not reuse the orange Brave Browser PNG. +pub const BRAVE_ORIGIN_SVG: &str = include_str!("../assets/logos/brave-origin.svg"); + +/// Original app tile PNGs. Brave Browser is `brave`; Origin is not in this list. +pub const APP_PNGS: &[(&str, &[u8])] = &[ + ("audacity", include_bytes!("../assets/logos/audacity.png")), + ("bottles", include_bytes!("../assets/logos/bottles.png")), + ("boxes", include_bytes!("../assets/logos/boxes.png")), + ("brave", include_bytes!("../assets/logos/brave.png")), + ("discord", include_bytes!("../assets/logos/discord.png")), + ("firefox", include_bytes!("../assets/logos/firefox.png")), + ("gimp", include_bytes!("../assets/logos/gimp.png")), + ( + "google-chrome", + include_bytes!("../assets/logos/google-chrome.png"), + ), + ("localsend", include_bytes!("../assets/logos/localsend.png")), + ("lutris", include_bytes!("../assets/logos/lutris.png")), + ( + "microsoft-edge", + include_bytes!("../assets/logos/microsoft-edge.png"), + ), + ( + "obs-studio", + include_bytes!("../assets/logos/obs-studio.png"), + ), + ( + "onlyoffice", + include_bytes!("../assets/logos/onlyoffice.png"), + ), + ("opera", include_bytes!("../assets/logos/opera.png")), + ( + "protonup-qt", + include_bytes!("../assets/logos/protonup-qt.png"), + ), + ( + "pycharm-community", + include_bytes!("../assets/logos/pycharm-community.png"), + ), + ("signal", include_bytes!("../assets/logos/signal.png")), + ("slack", include_bytes!("../assets/logos/slack.png")), + ("steam", include_bytes!("../assets/logos/steam.png")), + ( + "thunderbird", + include_bytes!("../assets/logos/thunderbird.png"), + ), + ( + "visual-studio-code", + include_bytes!("../assets/logos/visual-studio-code.png"), + ), + ("vivaldi", include_bytes!("../assets/logos/vivaldi.png")), + ("vlc", include_bytes!("../assets/logos/vlc.png")), + ("zen", include_bytes!("../assets/logos/zen.png")), +]; + +pub struct Raster { + pub size: [usize; 2], + pub rgba: Vec, +} + +fn svg_parse_options() -> usvg::Options<'static> { + let mut options = usvg::Options::default(); + options.image_href_resolver.resolve_string = Box::new(|_, _| None); + options +} + +/// Rasterize SVG markup that is already in memory. This never opens a path. +pub fn rasterize_svg_markup(svg: &str, size: u32) -> Option { + let options = svg_parse_options(); + let tree = usvg::Tree::from_str(svg, &options).ok()?; + let mut pixmap = tiny_skia::Pixmap::new(size, size)?; + let tree_size = tree.size(); + let scale = (size as f32 / tree_size.width()).min(size as f32 / tree_size.height()); + let tx = (size as f32 - tree_size.width() * scale) / 2.0; + let ty = (size as f32 - tree_size.height() * scale) / 2.0; + let transform = tiny_skia::Transform::from_translate(tx, ty).pre_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + Some(Raster { + size: [size as usize, size as usize], + rgba: pixmap.data().to_vec(), + }) +} + +pub fn color_image_from_raster(raster: &Raster) -> ColorImage { + ColorImage::from_rgba_unmultiplied(raster.size, &raster.rgba) +} + +pub fn decode_png(bytes: &[u8]) -> Option { + let image = image::load_from_memory(bytes).ok()?; + let rgba = image.to_rgba8(); + Some(ColorImage::from_rgba_unmultiplied( + [rgba.width() as usize, rgba.height() as usize], + rgba.as_raw(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn assert_markup(name: &str, svg: &str) { + assert!( + svg.contains(" tag" + ); + assert!( + !svg.starts_with("assets/"), + "{name} looks like a filesystem path rather than markup" + ); + } + + fn assert_raster(name: &str, svg: &str) { + let raster = rasterize_svg_markup(svg, 64) + .unwrap_or_else(|| panic!("{name} SVG failed to rasterize")); + assert_eq!(raster.size, [64, 64], "{name}"); + assert_eq!(raster.rgba.len(), 64 * 64 * 4, "{name}"); + assert!( + raster.rgba.chunks(4).any(|px| px[3] != 0), + "{name} raster was fully transparent" + ); + } + + #[test] + fn distro_svgs_are_embedded_markup() { + assert_eq!(DISTRO_SVGS.len(), 3); + for (name, svg) in DISTRO_SVGS { + assert_markup(name, svg); + assert_raster(name, svg); + } + } + + #[test] + fn brave_origin_svg_is_chris_markup() { + assert_markup("brave-origin", BRAVE_ORIGIN_SVG); + assert_raster("brave-origin", BRAVE_ORIGIN_SVG); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let on_disk = std::fs::read_to_string(root.join("assets/logos/brave-origin.svg")).unwrap(); + assert_eq!(BRAVE_ORIGIN_SVG, on_disk.as_str()); + } + + #[test] + fn app_pngs_decode_without_assets_dir() { + assert_eq!(APP_PNGS.len(), 24); + let keys: Vec<&str> = APP_PNGS.iter().map(|(key, _)| *key).collect(); + assert!(keys.contains(&"brave")); + assert!(!keys.contains(&"brave-origin")); + assert!(!keys.contains(&"htop")); + assert!(!keys.contains(&"libreoffice")); + assert!(!keys.contains(&"mpv")); + for (name, bytes) in APP_PNGS { + let image = decode_png(bytes).unwrap_or_else(|| panic!("{name} PNG failed to decode")); + assert!(image.width() > 0 && image.height() > 0, "{name}"); + } + } + + #[test] + fn include_str_and_bytes_match_checkout_files() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + for (name, embedded) in DISTRO_SVGS { + let path = root.join("assets/distros").join(format!("{name}.svg")); + let on_disk = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + assert_eq!(*embedded, on_disk.as_str(), "{name}"); + } + for (name, bytes) in APP_PNGS { + let path = root.join("assets/logos").join(format!("{name}.png")); + let on_disk = std::fs::read(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + assert_eq!(*bytes, on_disk.as_slice(), "{name}"); + } + } + + #[test] + fn assets_icons_dir_is_gone() { + let icons = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/icons"); + assert!(!icons.exists(), "assets/icons must stay removed"); + } + + #[test] + fn brave_origin_is_not_the_orange_brave_png() { + let brave = APP_PNGS + .iter() + .find(|(key, _)| *key == "brave") + .map(|(_, bytes)| *bytes) + .expect("brave png"); + assert!(!BRAVE_ORIGIN_SVG.as_bytes().eq(brave)); + assert!( + !BRAVE_ORIGIN_SVG.to_ascii_lowercase().contains("#fb542b"), + "Origin SVG must not use the orange Brave fill" + ); + let origin = rasterize_svg_markup(BRAVE_ORIGIN_SVG, 32).unwrap(); + let brave_img = decode_png(brave).unwrap(); + assert_ne!( + origin.rgba.len(), + 0, + "Origin SVG raster must produce pixels" + ); + assert!(brave_img.width() > 0); + } + + #[test] + fn rasterize_ignores_external_file_hrefs() { + let svg = r##" + + + "##; + let raster = rasterize_svg_markup(svg, 8).expect("markup with a missing href"); + assert!(raster.rgba.chunks(4).any(|px| px[3] != 0)); + } +} diff --git a/src/main.rs b/src/main.rs index 33d5aea..c47beee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod catalog; +mod logos; mod runner; mod system; mod validate; @@ -11,10 +12,9 @@ use std::{ }; use eframe::egui::{ - self, Align, Align2, Button, CentralPanel, Color32, ColorImage, Context, FontData, - FontDefinitions, FontFamily, FontId, Frame, Grid, Key, Layout, Painter, Panel, Pos2, Rect, - RichText, ScrollArea, Sense, Stroke, StrokeKind, TextEdit, TextureHandle, TextureOptions, Ui, - Vec2, pos2, vec2, + self, Align, Align2, Button, CentralPanel, Color32, Context, FontData, FontDefinitions, + FontFamily, FontId, Frame, Grid, Key, Layout, Painter, Panel, Pos2, Rect, RichText, ScrollArea, + Sense, Stroke, StrokeKind, TextEdit, TextureHandle, TextureOptions, Ui, Vec2, pos2, vec2, }; use catalog::{AdminTask, AppEntry, CatalogLoad, Task, admin_tasks, find_base_dir, load_apps}; @@ -950,130 +950,36 @@ fn install_fonts(ctx: &Context) { ctx.set_fonts(fonts); } +// App tiles: Chris's PNGs plus the Origin SVG, all compiled in. Missing the +// checkout's assets folder at runtime does not affect these textures. fn load_icons(ctx: &Context) -> HashMap<&'static str, TextureHandle> { let mut icons = HashMap::new(); - for (key, bytes) in [ - ( - "audacity", - include_bytes!("../assets/icons/audacity.png").as_slice(), - ), - ( - "bottles", - include_bytes!("../assets/icons/bottles.png").as_slice(), - ), - ( - "boxes", - include_bytes!("../assets/icons/boxes.png").as_slice(), - ), - ( - "brave", - include_bytes!("../assets/icons/brave.png").as_slice(), - ), - ( - "discord", - include_bytes!("../assets/icons/discord.png").as_slice(), - ), - ( - "firefox", - include_bytes!("../assets/icons/firefox.png").as_slice(), - ), - ( - "gimp", - include_bytes!("../assets/icons/gimp.png").as_slice(), - ), - ( - "google-chrome", - include_bytes!("../assets/icons/google-chrome.png").as_slice(), - ), - ( - "localsend", - include_bytes!("../assets/icons/localsend.png").as_slice(), - ), - ( - "lutris", - include_bytes!("../assets/icons/lutris.png").as_slice(), - ), - ( - "microsoft-edge", - include_bytes!("../assets/icons/microsoft-edge.png").as_slice(), - ), - ( - "obs-studio", - include_bytes!("../assets/icons/obs-studio.png").as_slice(), - ), - ( - "onlyoffice", - include_bytes!("../assets/icons/onlyoffice.png").as_slice(), - ), - ( - "opera", - include_bytes!("../assets/icons/opera.png").as_slice(), - ), - ( - "protonup-qt", - include_bytes!("../assets/icons/protonup-qt.png").as_slice(), - ), - ( - "pycharm-community", - include_bytes!("../assets/icons/pycharm-community.png").as_slice(), - ), - ( - "signal", - include_bytes!("../assets/icons/signal.png").as_slice(), - ), - ( - "slack", - include_bytes!("../assets/icons/slack.png").as_slice(), - ), - ( - "steam", - include_bytes!("../assets/icons/steam.png").as_slice(), - ), - ( - "thunderbird", - include_bytes!("../assets/icons/thunderbird.png").as_slice(), - ), - ( - "visual-studio-code", - include_bytes!("../assets/icons/visual-studio-code.png").as_slice(), - ), - ( - "vivaldi", - include_bytes!("../assets/icons/vivaldi.png").as_slice(), - ), - ("vlc", include_bytes!("../assets/icons/vlc.png").as_slice()), - ("zen", include_bytes!("../assets/icons/zen.png").as_slice()), - ] { - if let Ok(image) = image::load_from_memory(bytes) { - let rgba = image.to_rgba8(); - let size = [rgba.width() as usize, rgba.height() as usize]; - let color_image = ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()); + for &(key, bytes) in crate::logos::APP_PNGS { + if let Some(color_image) = crate::logos::decode_png(bytes) { icons.insert( key, - ctx.load_texture(key, color_image, TextureOptions::LINEAR), + ctx.load_texture(format!("app-{key}"), color_image, TextureOptions::LINEAR), ); } } + if let Some(raster) = crate::logos::rasterize_svg_markup(crate::logos::BRAVE_ORIGIN_SVG, 64) { + icons.insert( + "brave-origin", + ctx.load_texture( + "app-brave-origin", + crate::logos::color_image_from_raster(&raster), + TextureOptions::LINEAR, + ), + ); + } icons } fn load_distro_icons(ctx: &Context) -> HashMap<&'static str, TextureHandle> { let mut icons = HashMap::new(); - for (key, svg) in [ - ( - "arch", - include_bytes!("../assets/distros/arch.svg").as_slice(), - ), - ( - "debian", - include_bytes!("../assets/distros/debian.svg").as_slice(), - ), - ( - "fedora", - include_bytes!("../assets/distros/fedora.svg").as_slice(), - ), - ] { - if let Some(color_image) = rasterize_svg(svg, 64) { + for &(key, svg) in crate::logos::DISTRO_SVGS { + if let Some(raster) = crate::logos::rasterize_svg_markup(svg, 64) { + let color_image = crate::logos::color_image_from_raster(&raster); icons.insert( key, ctx.load_texture(format!("distro-{key}"), color_image, TextureOptions::LINEAR), @@ -1083,22 +989,6 @@ fn load_distro_icons(ctx: &Context) -> HashMap<&'static str, TextureHandle> { icons } -fn rasterize_svg(svg: &[u8], size: u32) -> Option { - let options = usvg::Options::default(); - let tree = usvg::Tree::from_data(svg, &options).ok()?; - let mut pixmap = tiny_skia::Pixmap::new(size, size)?; - let tree_size = tree.size(); - let scale = (size as f32 / tree_size.width()).min(size as f32 / tree_size.height()); - let tx = (size as f32 - tree_size.width() * scale) / 2.0; - let ty = (size as f32 - tree_size.height() * scale) / 2.0; - let transform = tiny_skia::Transform::from_translate(tx, ty).pre_scale(scale, scale); - resvg::render(&tree, transform, &mut pixmap.as_mut()); - Some(ColorImage::from_rgba_unmultiplied( - [size as usize, size as usize], - pixmap.data(), - )) -} - fn apply_theme(ctx: &Context) { let mut style = (*ctx.global_style()).clone(); style.spacing.item_spacing = Vec2::new(8.0, 6.0); @@ -1874,7 +1764,7 @@ fn distro_selected(label: &str, package_manager: &str, distro_name: &str) -> boo fn app_color(label: &str) -> Color32 { match label { "Firefox" => Color32::from_rgb(238, 90, 47), - "Brave Browser" => Color32::from_rgb(246, 85, 42), + "Brave Browser" | "Brave Origin" => Color32::from_rgb(246, 85, 42), "Google Chrome" => Color32::from_rgb(66, 133, 244), "Microsoft Edge" => Color32::from_rgb(19, 158, 175), "Opera" => Color32::from_rgb(218, 38, 55), @@ -1897,7 +1787,7 @@ fn app_mark(label: &str) -> &str { "PyCharm Community" => "PC", "Google Chrome" => "C", "Microsoft Edge" => "E", - "Brave Browser" => "B", + "Brave Origin" => "O", "Thunderbird" => "T", "ProtonUp-Qt" => "P", _ => label.get(0..1).unwrap_or("*"), @@ -1907,6 +1797,7 @@ fn app_mark(label: &str) -> &str { fn app_description(label: &str) -> &'static str { match label { "Brave Browser" => "Privacy-focused browser", + "Brave Origin" => "Native Origin browser", "Google Chrome" => "The web browser from Google", "Firefox" => "Web browser from Mozilla", "Microsoft Edge" => "Microsoft's web browser", @@ -1956,6 +1847,7 @@ fn icon_key(label: &str) -> &'static str { "Bottles" | "bottles" => "bottles", "Boxes" | "gnome-boxes" => "boxes", "Brave Browser" | "brave" => "brave", + "Brave Origin" | "brave-origin" => "brave-origin", "Discord" | "discord" => "discord", "Firefox" | "firefox" => "firefox", "GIMP" | "gimp" => "gimp", diff --git a/src/validate.rs b/src/validate.rs index b4d8f58..1f3a866 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -231,6 +231,8 @@ mod tests { "libreoffice", "gparted", "htop", + "brave-origin", + "brave-origin-bin", ] { assert!(is_package_name(name), "{name}"); } @@ -340,6 +342,7 @@ mod tests { #[test] fn labels_and_notes_reject_control_characters() { assert!(is_label("Brave Browser")); + assert!(is_label("Brave Origin")); assert!(is_label("nala (rank mirrors) - Debian only")); assert!(!is_label("bad\nlabel")); assert!(!is_label("-sneaky")); @@ -356,6 +359,8 @@ mod tests { let cases = [ ("is_valid_package_name", "firefox", true), ("is_valid_package_name", "obs-studio", true), + ("is_valid_package_name", "brave-origin", true), + ("is_valid_package_name", "brave-origin-bin", true), ("is_valid_package_name", "-Syu", false), ("is_valid_package_name", "pkg;id", false), ("is_valid_flatpak_id", "com.brave.Browser", true), @@ -386,4 +391,218 @@ mod tests { ); } } + + fn toolbox_lib() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toolbox-lib.sh") + } + + fn bash_lib(script: &str) -> std::process::Output { + std::process::Command::new("bash") + .arg("-c") + .arg(format!("source \"$1\" && {script}")) + .arg("brave-test") + .arg(toolbox_lib()) + .output() + .expect("run bash toolbox-lib helper") + } + + #[test] + fn brave_download_urls_are_pinned_and_other_urls_are_rejected() { + let ok_script = bash_lib( + r#" + is_pinned_brave_download_url "$BRAVE_INSTALL_SH_URL" || exit 1 + is_pinned_brave_download_url "$BRAVE_INSTALL_SH_ASC_URL" || exit 2 + is_pinned_brave_download_url "https://evil.example/install.sh" && exit 3 + is_pinned_brave_download_url "https://dl.brave.com/other.sh" && exit 4 + dest=$(mktemp) + if download_pinned_brave_file "https://example.com/install.sh" "$dest"; then + rm -f "$dest" + exit 5 + fi + rm -f "$dest" + [[ "$BRAVE_INSTALL_SH_URL" == "https://dl.brave.com/install.sh" ]] || exit 6 + [[ "$BRAVE_INSTALL_SH_ASC_URL" == "https://dl.brave.com/install.sh.asc" ]] || exit 7 + [[ "$BRAVE_INSTALL_SH_FINGERPRINT" == "D16166072CACDF2C9429CBF11BF41E37D039F691" ]] || exit 8 + "#, + ); + assert!( + ok_script.status.success(), + "stderr: {}", + String::from_utf8_lossy(&ok_script.stderr) + ); + } + + #[test] + fn brave_origin_package_candidates_match_distro() { + let output = bash_lib( + r#" + brave_origin_package_candidates pacman + echo --- + brave_origin_package_candidates apt + echo --- + brave_origin_package_candidates dnf + "#, + ); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let chunks: Vec<&str> = stdout.split("---\n").map(str::trim).collect(); + assert_eq!(chunks[0], "brave-origin-bin\nbrave-origin"); + assert_eq!(chunks[1], "brave-origin"); + assert_eq!(chunks[2], "brave-origin"); + } + + #[test] + fn brave_gpg_verify_fails_closed_on_tampered_script() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let key = root.join("assets/keys/brave-install.sh.asc"); + assert!(key.is_file(), "vendored Brave installer key"); + + let output = std::process::Command::new("bash") + .arg("-c") + .arg( + r#" + source "$1" + tmp=$(mktemp -d) + printf '%s\n' '#!/bin/sh' 'echo pwned' > "$tmp/install.sh" + printf '%s\n' 'not a signature' > "$tmp/install.sh.asc" + if verify_brave_install_script "$tmp/install.sh" "$tmp/install.sh.asc" "$2"; then + rm -rf "$tmp" + exit 1 + fi + rm -rf "$tmp" + "#, + ) + .arg("brave-verify") + .arg(toolbox_lib()) + .arg(&key) + .output() + .expect("run gpg fail-closed test"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("GPG verification of the Brave installer failed") + || stderr.contains("Failed to import"), + "unexpected stderr: {stderr}" + ); + } + + #[test] + fn brave_installer_helpers_do_not_pipe_curl_to_sh_or_eval() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let main = std::fs::read_to_string(root.join("main.sh")).unwrap(); + let lib = std::fs::read_to_string(root.join("toolbox-lib.sh")).unwrap(); + let combined = format!("{main}\n{lib}"); + + assert!( + !combined.contains("| FLAVOR=origin"), + "must not pipe the Brave installer into a shell" + ); + assert!( + !combined.contains("curl -fsS https://dl.brave.com/install.sh |"), + "must not curl|sh the Brave installer" + ); + assert!( + !combined.contains("eval "), + "must not eval the Brave installer" + ); + assert!( + combined.contains("FLAVOR=origin CHANNEL=release"), + "verified installer must run with FLAVOR=origin" + ); + assert!( + lib.contains("sudo -n"), + "nested sudo must be non-interactive" + ); + + for url in http_urls_containing(&combined, "brave") { + assert!( + url == "https://dl.brave.com/install.sh" + || url == "https://dl.brave.com/install.sh.asc", + "unpinned Brave download URL in helpers: {url}" + ); + } + } + + fn http_urls_containing<'a>(text: &'a str, needle: &str) -> Vec<&'a str> { + text.split_whitespace() + .filter_map(|part| { + let start = part.find("https://")?; + let url = part[start..].trim_end_matches(|c: char| { + matches!(c, '"' | '\'' | ')' | ';' | ',' | '`' | '.') + }); + url.contains(needle).then_some(url) + }) + .collect() + } + + #[test] + fn sudo_wrapper_invokes_real_sudo_with_n() { + let output = bash_lib( + r#" + tmp=$(mktemp -d) + install_noninteractive_sudo_wrapper "$tmp" || exit 1 + grep -q -- '-n' "$tmp/sudo" || exit 2 + if grep -q -- 'sudo -S' "$tmp/sudo"; then + rm -rf "$tmp" + exit 3 + fi + test -x "$tmp/sudo" || exit 4 + rm -rf "$tmp" + "#, + ); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn brave_gpg_verify_accepts_official_signed_installer() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let key = root.join("assets/keys/brave-install.sh.asc"); + let output = std::process::Command::new("bash") + .arg("-c") + .arg( + r#" + source "$1" + tmp=$(mktemp -d) + if ! download_pinned_brave_file "$BRAVE_INSTALL_SH_URL" "$tmp/install.sh"; then + rm -rf "$tmp" + exit 1 + fi + if ! download_pinned_brave_file "$BRAVE_INSTALL_SH_ASC_URL" "$tmp/install.sh.asc"; then + rm -rf "$tmp" + exit 2 + fi + if ! verify_brave_install_script "$tmp/install.sh" "$tmp/install.sh.asc" "$2"; then + rm -rf "$tmp" + exit 3 + fi + # Tampering after a good download must fail closed. + echo '# tampered' >> "$tmp/install.sh" + if verify_brave_install_script "$tmp/install.sh" "$tmp/install.sh.asc" "$2"; then + rm -rf "$tmp" + exit 4 + fi + rm -rf "$tmp" + "#, + ) + .arg("brave-verify-ok") + .arg(toolbox_lib()) + .arg(&key) + .output() + .expect("run official installer verify test"); + assert!( + output.status.success(), + "status={} stderr={} stdout={}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + } } diff --git a/toolbox-lib.sh b/toolbox-lib.sh index 3af5656..dec089a 100644 --- a/toolbox-lib.sh +++ b/toolbox-lib.sh @@ -65,3 +65,125 @@ require_action() { arch_multilib_enabled() { [[ -r /etc/pacman.conf ]] && grep -q '^\[multilib\]' /etc/pacman.conf } + +# Official Brave Origin installer. These are the only download URLs this +# toolbox is allowed to fetch; the signature is verified before the script runs. +BRAVE_INSTALL_SH_URL="https://dl.brave.com/install.sh" +BRAVE_INSTALL_SH_ASC_URL="https://dl.brave.com/install.sh.asc" +BRAVE_INSTALL_SH_FINGERPRINT="D16166072CACDF2C9429CBF11BF41E37D039F691" + +is_pinned_brave_download_url() { + local url=${1:-} + [[ "$url" == "$BRAVE_INSTALL_SH_URL" || "$url" == "$BRAVE_INSTALL_SH_ASC_URL" ]] +} + +# Debian/Fedora package: brave-origin. Arch AUR: brave-origin-bin. +brave_origin_package_candidates() { + local package_manager=${1:-} + case "$package_manager" in + pacman) + printf '%s\n' "brave-origin-bin" "brave-origin" + ;; + *) + printf '%s\n' "brave-origin" + ;; + esac +} + +download_pinned_brave_file() { + local url=${1:-} + local dest=${2:-} + if ! is_pinned_brave_download_url "$url"; then + echo "Refusing to download unpinned URL." >&2 + return 1 + fi + if [[ -z "$dest" ]]; then + echo "Download destination is required." >&2 + return 1 + fi + # Follow HTTPS redirects from the pinned URL (the .asc file is served via CDN). + # Do not name any other download URL in this toolbox. + if ! curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 --max-time 60 -o "$dest" "$url"; then + echo "Failed to download $url" >&2 + return 1 + fi + if [[ ! -s "$dest" ]]; then + echo "Downloaded empty file from $url" >&2 + return 1 + fi +} + +# Import the vendored Brave installer key into an isolated GNUPGHOME and +# require VALIDSIG for D16166072CACDF2C9429CBF11BF41E37D039F691. +verify_brave_install_script() { + local script=${1:-} + local signature=${2:-} + local key_file=${3:-} + local gnupg_home status_file + + if [[ ! -f "$script" || ! -s "$script" ]]; then + echo "Brave installer script is missing." >&2 + return 1 + fi + if [[ ! -f "$signature" || ! -s "$signature" ]]; then + echo "Brave installer signature is missing." >&2 + return 1 + fi + if [[ ! -f "$key_file" || ! -s "$key_file" ]]; then + echo "Brave installer signing key is missing." >&2 + return 1 + fi + if ! command -v gpg >/dev/null 2>&1; then + echo "gpg is required to verify the Brave installer." >&2 + return 1 + fi + + gnupg_home=$(mktemp -d) || return 1 + status_file=$(mktemp) || { + rm -rf "$gnupg_home" + return 1 + } + chmod 700 "$gnupg_home" + + if ! GNUPGHOME="$gnupg_home" gpg --batch --quiet --import "$key_file" >/dev/null 2>&1; then + rm -rf "$gnupg_home" "$status_file" + echo "Failed to import Brave installer signing key." >&2 + return 1 + fi + + if ! GNUPGHOME="$gnupg_home" gpg --batch --status-file "$status_file" --verify "$signature" "$script" >/dev/null 2>&1; then + rm -rf "$gnupg_home" "$status_file" + echo "GPG verification of the Brave installer failed." >&2 + return 1 + fi + + if ! grep -q "^\[GNUPG:\] VALIDSIG ${BRAVE_INSTALL_SH_FINGERPRINT} " "$status_file"; then + rm -rf "$gnupg_home" "$status_file" + echo "GPG verification of the Brave installer failed." >&2 + return 1 + fi + + rm -rf "$gnupg_home" "$status_file" + return 0 +} + +# Prepend a `sudo -n` wrapper so nested sudo in the Brave installer cannot +# hang on an interactive password prompt. Relies on Toolbox's `sudo -S -v` cache. +install_noninteractive_sudo_wrapper() { + local wrap_dir=${1:-} + local real_sudo + if [[ -z "$wrap_dir" ]]; then + echo "sudo wrapper directory is required." >&2 + return 1 + fi + real_sudo=$(command -v sudo) || { + echo "sudo was not found on PATH." >&2 + return 1 + } + mkdir -p "$wrap_dir" + cat >"$wrap_dir/sudo" <