|
| 1 | +#!/usr/bin/env bash |
| 2 | +# ─── lib/ext.sh ─── browser extension manager (sync/list) ── |
| 3 | + |
| 4 | +__ext_manifest() { |
| 5 | + echo "${DOTSEC_EXT_MANIFEST:-${DOTSEC_HOME}/chromium/extensions.list}" |
| 6 | +} |
| 7 | + |
| 8 | +__ext_dir() { |
| 9 | + echo "${DOTSEC_EXT_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/dotenvsec/extensions}" |
| 10 | +} |
| 11 | + |
| 12 | +# Trim leading/trailing whitespace (pure bash, no xargs surprises on quotes). |
| 13 | +__ext_trim() { |
| 14 | + local s="$1" |
| 15 | + s="${s#"${s%%[![:space:]]*}"}" |
| 16 | + s="${s%"${s##*[![:space:]]}"}" |
| 17 | + printf '%s' "$s" |
| 18 | +} |
| 19 | + |
| 20 | +# Parse the manifest, invoking <callback> with the 6 trimmed fields per entry. |
| 21 | +# Manifest line: name | provider | source | ref | sha256 | subdir (# = comment) |
| 22 | +__ext_each() { |
| 23 | + local cb="$1" manifest name provider src ref sha subdir |
| 24 | + manifest="$(__ext_manifest)" |
| 25 | + if [[ ! -f "$manifest" ]]; then |
| 26 | + printf '%b\n' "${RED}[!] No manifest: ${manifest}${RESET}" >&2 |
| 27 | + return 1 |
| 28 | + fi |
| 29 | + while IFS='|' read -r name provider src ref sha subdir; do |
| 30 | + name="$(__ext_trim "${name:-}")" |
| 31 | + [[ -z "$name" || "$name" == \#* ]] && continue |
| 32 | + provider="$(__ext_trim "${provider:-}")" |
| 33 | + src="$(__ext_trim "${src:-}")" |
| 34 | + ref="$(__ext_trim "${ref:-}")" |
| 35 | + sha="$(__ext_trim "${sha:-}")" |
| 36 | + subdir="$(__ext_trim "${subdir:-}")" |
| 37 | + "$cb" "$name" "$provider" "$src" "$ref" "$sha" "$subdir" |
| 38 | + done < "$manifest" |
| 39 | +} |
| 40 | + |
| 41 | +__ext_list_one() { |
| 42 | + local name="$1" provider="$2" ref="$4" |
| 43 | + local state="${RED}missing${RESET}" |
| 44 | + [[ -f "$(__ext_dir)/${name}/manifest.json" ]] && state="${GREEN}ok${RESET}" |
| 45 | + printf ' %-18s %-9s %-12s %b\n' "$name" "$provider" "$ref" "$state" |
| 46 | +} |
| 47 | + |
| 48 | +ext_list() { |
| 49 | + printf '%b\n' "${DIM}Extensions dir: $(__ext_dir)${RESET}" |
| 50 | + __ext_each __ext_list_one |
| 51 | +} |
| 52 | + |
| 53 | +# __ext_fetch_github <name> <repo> <tag> <sha256> <subdir> |
| 54 | +# Downloads the tag tarball, verifies sha256, extracts the unpacked extension |
| 55 | +# (subdir, or repo root) into $DOTSEC_EXT_DIR/<name>/. The body runs in a |
| 56 | +# subshell so the tmp-cleanup trap cannot fire on a caller's later return. |
| 57 | +__ext_fetch_github() { |
| 58 | + local name="$1" repo="$2" tag="$3" want="$4" subdir="${5:-.}" |
| 59 | + local dir; dir="$(__ext_dir)" |
| 60 | + local url="https://github.com/${repo}/archive/refs/tags/${tag}.tar.gz" |
| 61 | + ( |
| 62 | + set -euo pipefail |
| 63 | + local tmp; tmp="$(mktemp -d)" |
| 64 | + trap 'rm -rf "$tmp"' EXIT |
| 65 | + if ! curl -fsSL "$url" -o "$tmp/dl.tar.gz"; then |
| 66 | + printf '%b\n' " ${RED}[!] download failed: ${url}${RESET}" >&2 |
| 67 | + exit 1 |
| 68 | + fi |
| 69 | + local got; got="$(sha256sum "$tmp/dl.tar.gz" | cut -d' ' -f1)" |
| 70 | + if [[ -n "$want" && "$got" != "$want" ]]; then |
| 71 | + printf '%b\n' " ${RED}[!] sha256 mismatch for ${name}${RESET}" >&2 |
| 72 | + printf '%b\n' " want ${want}" >&2 |
| 73 | + printf '%b\n' " got ${got}" >&2 |
| 74 | + exit 1 |
| 75 | + fi |
| 76 | + mkdir -p "$tmp/x" |
| 77 | + tar -xzf "$tmp/dl.tar.gz" -C "$tmp/x" --strip-components=1 |
| 78 | + local src="$tmp/x" |
| 79 | + if [[ "$subdir" != "." && -n "$subdir" ]]; then |
| 80 | + src="$tmp/x/$subdir" |
| 81 | + fi |
| 82 | + if [[ ! -f "$src/manifest.json" ]]; then |
| 83 | + printf '%b\n' " ${RED}[!] no manifest.json in ${name} (${subdir})${RESET}" >&2 |
| 84 | + exit 1 |
| 85 | + fi |
| 86 | + rm -rf "${dir:?}/$name" |
| 87 | + mkdir -p "$dir/$name" |
| 88 | + cp -a "$src/." "$dir/$name/" |
| 89 | + echo "$tag" > "$dir/$name/.dotsec-version" |
| 90 | + printf '%b\n' " ${GREEN}[+]${RESET} ${name} ${DIM}(${tag})${RESET}" |
| 91 | + ) |
| 92 | +} |
| 93 | + |
| 94 | +# __ext_fetch_webstore <name> <id> <version> <sha256> |
| 95 | +# Downloads the pinned .crx, verifies sha256, carves out the zip payload |
| 96 | +# (CRX2/CRX3 header skipped via the first PK\x03\x04), unzips into |
| 97 | +# $DOTSEC_EXT_DIR/<name>/. |
| 98 | +__ext_fetch_webstore() { |
| 99 | + local name="$1" id="$2" ver="$3" want="$4" |
| 100 | + local dir; dir="$(__ext_dir)" |
| 101 | + # prodversion is the BROWSER version Google serves a compatible crx for, not |
| 102 | + # the extension version (the URL always returns the latest; ver pins for the |
| 103 | + # idempotence marker and a sha256 mismatch flags any upstream change). |
| 104 | + local browser_ver="${DOTSEC_CHROME_MAJOR:-149}" |
| 105 | + local url="https://clients2.google.com/service/update2/crx?response=redirect&acceptformat=crx2,crx3&prodversion=${browser_ver}&x=id%3D${id}%26installsource%3Dondemand%26uc" |
| 106 | + ( |
| 107 | + set -euo pipefail |
| 108 | + local tmp; tmp="$(mktemp -d)" |
| 109 | + trap 'rm -rf "$tmp"' EXIT |
| 110 | + if ! curl -fsSL "$url" -o "$tmp/e.crx"; then |
| 111 | + printf '%b\n' " ${RED}[!] crx download failed: ${name}${RESET}" >&2 |
| 112 | + exit 1 |
| 113 | + fi |
| 114 | + local got; got="$(sha256sum "$tmp/e.crx" | cut -d' ' -f1)" |
| 115 | + if [[ -n "$want" && "$got" != "$want" ]]; then |
| 116 | + printf '%b\n' " ${RED}[!] sha256 mismatch for ${name}${RESET}" >&2 |
| 117 | + printf '%b\n' " want ${want}" >&2 |
| 118 | + printf '%b\n' " got ${got}" >&2 |
| 119 | + exit 1 |
| 120 | + fi |
| 121 | + # head -1: grep -o can emit several matches from one binary "line". |
| 122 | + local off; off="$(grep -aboFm1 $'PK\x03\x04' "$tmp/e.crx" | head -1 | cut -d: -f1 || true)" |
| 123 | + if [[ -z "$off" ]]; then |
| 124 | + printf '%b\n' " ${RED}[!] no zip payload in crx ${name}${RESET}" >&2 |
| 125 | + exit 1 |
| 126 | + fi |
| 127 | + tail -c "+$((off + 1))" "$tmp/e.crx" > "$tmp/e.zip" |
| 128 | + mkdir -p "$tmp/x" |
| 129 | + unzip -qo "$tmp/e.zip" -d "$tmp/x" |
| 130 | + if [[ ! -f "$tmp/x/manifest.json" ]]; then |
| 131 | + printf '%b\n' " ${RED}[!] no manifest.json in crx ${name}${RESET}" >&2 |
| 132 | + exit 1 |
| 133 | + fi |
| 134 | + rm -rf "${dir:?}/$name" |
| 135 | + mkdir -p "$dir/$name" |
| 136 | + cp -a "$tmp/x/." "$dir/$name/" |
| 137 | + echo "$ver" > "$dir/$name/.dotsec-version" |
| 138 | + printf '%b\n' " ${GREEN}[+]${RESET} ${name} ${DIM}(crx ${ver})${RESET}" |
| 139 | + ) |
| 140 | +} |
| 141 | + |
| 142 | +# __ext_sync_one <name> <provider> <source> <ref> <sha256> <subdir> |
| 143 | +__ext_sync_one() { |
| 144 | + local name="$1" provider="$2" src="$3" ref="$4" sha="$5" subdir="$6" |
| 145 | + if [[ -n "${__EXT_ONLY:-}" && "${__EXT_ONLY}" != "$name" ]]; then |
| 146 | + return 0 |
| 147 | + fi |
| 148 | + local marker; marker="$(__ext_dir)/$name/.dotsec-version" |
| 149 | + if [[ -f "$marker" ]] && [[ "$(cat "$marker")" == "$ref" ]]; then |
| 150 | + printf '%b\n' " ${DIM}= ${name} (up-to-date ${ref})${RESET}" |
| 151 | + return 0 |
| 152 | + fi |
| 153 | + # One failing extension must not abort the whole sync; the error is printed. |
| 154 | + case "$provider" in |
| 155 | + github) __ext_fetch_github "$name" "$src" "$ref" "$sha" "$subdir" || true;; |
| 156 | + webstore) __ext_fetch_webstore "$name" "$src" "$ref" "$sha" || true;; |
| 157 | + *) printf '%b\n' " ${RED}[!] unknown provider '${provider}' for ${name}${RESET}" >&2;; |
| 158 | + esac |
| 159 | +} |
| 160 | + |
| 161 | +# ext_sync [only]: install all (or one named) manifest entries into the ext dir. |
| 162 | +ext_sync() { |
| 163 | + local only="${1:-}" |
| 164 | + local dir; dir="$(__ext_dir)" |
| 165 | + mkdir -p "$dir" |
| 166 | + printf '%b\n' "${YELLOW}[*]${RESET} Syncing extensions into ${CYAN}${dir}${RESET}" |
| 167 | + __EXT_ONLY="$only" |
| 168 | + __ext_each __ext_sync_one |
| 169 | + unset __EXT_ONLY |
| 170 | +} |
| 171 | + |
| 172 | +cmd_ext() { |
| 173 | + case "${1:-}" in |
| 174 | + list) shift; ext_list "$@";; |
| 175 | + sync) shift; ext_sync "$@";; |
| 176 | + *) printf '%b\n' "${RED}[!] dotsec ext sync|list${RESET}" >&2; return 1;; |
| 177 | + esac |
| 178 | +} |
0 commit comments