YTLitePlus_/lib.sh
copilot-swe-agent[bot] 6eeac05d8c Add v2 redesign: config.yml, lib.sh, build.sh, and GitHub Actions workflow
Co-authored-by: Balackburn <93828569+Balackburn@users.noreply.github.com>
2026-02-12 19:16:08 +00:00

606 lines
20 KiB
Bash
Executable file

#!/usr/bin/env bash
# lib.sh — Shared core logic for YTLitePlus build system
# Used by both build.sh (local) and the GitHub Actions workflow.
# All tweak fetching, IPA patching, and customization logic lives here.
set -euo pipefail
###############################################################################
# Globals
###############################################################################
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/config.yml"
WORK_DIR="${SCRIPT_DIR}/_work"
TWEAKS_DIR="${WORK_DIR}/tweaks"
DYLIBS_DIR="${WORK_DIR}/dylibs"
FRAMEWORKS_DIR="${WORK_DIR}/frameworks"
PAYLOAD_DIR="${WORK_DIR}/payload"
###############################################################################
# Colour helpers (no-op when stdout is not a terminal)
###############################################################################
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'
YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m'
else
RED=''; GREEN=''; BLUE=''; YELLOW=''; BOLD=''; NC=''
fi
log_info() { echo -e "${BLUE}==> ${BOLD}$*${NC}"; }
log_ok() { echo -e "${GREEN}$*${NC}"; }
log_warn() { echo -e "${YELLOW}$*${NC}"; }
log_error() { echo -e "${RED}$*${NC}"; }
die() { log_error "$@"; exit 1; }
###############################################################################
# YAML parsing via Python (works on macOS + Linux without extra deps)
###############################################################################
# Returns the number of enabled tweaks
config_tweak_count() {
python3 -c "
import yaml, sys
with open('${CONFIG_FILE}') as f:
cfg = yaml.safe_load(f)
print(sum(1 for t in cfg.get('tweaks', []) if t.get('enabled', False)))
"
}
# Returns a JSON array of enabled tweaks (consumed by bash via jq)
config_enabled_tweaks_json() {
python3 -c "
import yaml, json, sys
with open('${CONFIG_FILE}') as f:
cfg = yaml.safe_load(f)
enabled = [t for t in cfg.get('tweaks', []) if t.get('enabled', False)]
print(json.dumps(enabled))
"
}
# Returns a single customization value by key
config_custom() {
local key="$1"
python3 -c "
import yaml, sys
with open('${CONFIG_FILE}') as f:
cfg = yaml.safe_load(f)
val = cfg.get('customization', {}).get('${key}', '')
print(val if val is not None else '')
"
}
###############################################################################
# Dependency checks
###############################################################################
check_deps() {
local missing=()
for cmd in curl jq python3 unzip zip; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
# We need either insert_dylib or optool for dylib injection
if ! command -v insert_dylib &>/dev/null && ! command -v optool &>/dev/null; then
missing+=("insert_dylib or optool")
fi
# Check if any tweak needs building (fetch: build)
local needs_build
needs_build=$(python3 -c "
import yaml
with open('${CONFIG_FILE}') as f:
cfg = yaml.safe_load(f)
print('yes' if any(t.get('fetch') == 'build' and t.get('enabled', False) for t in cfg.get('tweaks', [])) else 'no')
")
if [ "$needs_build" = "yes" ]; then
command -v make &>/dev/null || missing+=("make")
if [ -z "${THEOS:-}" ]; then
log_warn "THEOS env var not set — source-build tweaks may fail"
fi
fi
# python3 needs pyyaml
python3 -c "import yaml" 2>/dev/null || missing+=("python3-yaml (pip install pyyaml)")
if [ ${#missing[@]} -gt 0 ]; then
die "Missing dependencies: ${missing[*]}"
fi
log_ok "All dependencies satisfied"
}
###############################################################################
# Workspace setup / teardown
###############################################################################
prepare_workspace() {
rm -rf "${WORK_DIR}"
mkdir -p "${TWEAKS_DIR}" "${DYLIBS_DIR}" "${FRAMEWORKS_DIR}" "${PAYLOAD_DIR}"
log_ok "Workspace prepared at ${WORK_DIR}"
}
cleanup_workspace() {
rm -rf "${WORK_DIR}"
}
###############################################################################
# IPA extraction
###############################################################################
# $1 = path or URL to the decrypted YouTube IPA
extract_ipa() {
local ipa_source="$1"
local ipa_path="${WORK_DIR}/YouTube.ipa"
if [[ "$ipa_source" == http* ]]; then
log_info "Downloading IPA from URL…"
curl -fSL "$ipa_source" -o "$ipa_path" || die "Failed to download IPA"
else
[ -f "$ipa_source" ] || die "IPA file not found: $ipa_source"
cp "$ipa_source" "$ipa_path"
fi
log_info "Extracting IPA…"
unzip -q "$ipa_path" -d "${PAYLOAD_DIR}" || die "Failed to unzip IPA"
# Locate YouTube.app inside Payload
APP_DIR=$(find "${PAYLOAD_DIR}/Payload" -maxdepth 1 -name "*.app" -type d | head -n 1)
[ -d "$APP_DIR" ] || die "No .app found inside IPA"
log_ok "Extracted $(basename "$APP_DIR")"
}
###############################################################################
# Tweak fetching — release mode
###############################################################################
# Picks the best asset from a GitHub release.
# Priority: .dylib > .deb > .framework.zip > .zip
# $1 = repo (owner/name), $2 = tweak id, $3 = "always" flag (optional)
fetch_release_tweak() {
local repo="$1" tweak_id="$2"
local api_url="https://api.github.com/repos/${repo}/releases/latest"
local tweak_dir="${TWEAKS_DIR}/${tweak_id}"
mkdir -p "$tweak_dir"
log_info "Fetching latest release for ${repo}"
local release_json
release_json=$(curl -fsSL "$api_url") || die "GitHub API request failed for ${repo}"
# Build a priority-ordered list of candidate asset URLs
local dylib_url deb_url framework_url zip_url chosen_url chosen_name
# Parse assets with jq — pick first match per category
dylib_url=$(echo "$release_json" | jq -r '[.assets[] | select(.name | test("\\.dylib$"; "i"))][0].browser_download_url // empty')
deb_url=$(echo "$release_json" | jq -r '[.assets[] | select(.name | test("\\.deb$"; "i"))][0].browser_download_url // empty')
framework_url=$(echo "$release_json" | jq -r '[.assets[] | select(.name | test("\\.framework\\.zip$"; "i"))][0].browser_download_url // empty')
zip_url=$(echo "$release_json" | jq -r '[.assets[] | select(.name | test("\\.zip$"; "i")) | select(.name | test("\\.framework\\.zip$"; "i") | not)][0].browser_download_url // empty')
if [ -n "$dylib_url" ]; then
chosen_url="$dylib_url"
elif [ -n "$deb_url" ]; then
chosen_url="$deb_url"
elif [ -n "$framework_url" ]; then
chosen_url="$framework_url"
elif [ -n "$zip_url" ]; then
chosen_url="$zip_url"
else
die "No usable asset found in latest release of ${repo}"
fi
chosen_name=$(basename "$chosen_url")
log_info " → Downloading ${chosen_name}"
curl -fSL "$chosen_url" -o "${tweak_dir}/${chosen_name}" || die "Failed to download ${chosen_name}"
# Extract the injectable artefact
extract_tweak_artifact "${tweak_dir}" "${chosen_name}" "${tweak_id}"
}
###############################################################################
# Tweak fetching — build mode
###############################################################################
# $1 = repo, $2 = tweak id, $3 = branch (optional), $4 = build_cmd (optional)
fetch_build_tweak() {
local repo="$1" tweak_id="$2" branch="${3:-}" build_cmd="${4:-make package}"
local clone_dir="${TWEAKS_DIR}/${tweak_id}/src"
log_info "Building ${tweak_id} from source (${repo})…"
local clone_args=("--depth=1")
[ -n "$branch" ] && clone_args+=("--branch" "$branch")
git clone "${clone_args[@]}" "https://github.com/${repo}.git" "$clone_dir" \
|| die "Failed to clone ${repo}"
(
cd "$clone_dir"
eval "$build_cmd"
) || die "Build failed for ${tweak_id}"
# Search for output .dylib or .deb in the build tree
local found_dylib found_deb
found_dylib=$(find "$clone_dir" -name "*.dylib" -type f | head -n 1)
found_deb=$(find "$clone_dir/packages" -name "*.deb" -type f 2>/dev/null | head -n 1)
if [ -n "$found_dylib" ]; then
cp "$found_dylib" "${DYLIBS_DIR}/${tweak_id}.dylib"
log_ok " → Built dylib: ${tweak_id}.dylib"
elif [ -n "$found_deb" ]; then
extract_dylib_from_deb "$found_deb" "$tweak_id"
else
die "No .dylib or .deb output found after building ${tweak_id}"
fi
}
###############################################################################
# Artifact extraction helpers
###############################################################################
# Inspects a downloaded file and places the final .dylib / .framework into
# the appropriate output directory.
extract_tweak_artifact() {
local dir="$1" filename="$2" tweak_id="$3"
local filepath="${dir}/${filename}"
case "$filename" in
*.dylib)
cp "$filepath" "${DYLIBS_DIR}/${tweak_id}.dylib"
log_ok " → Ready: ${tweak_id}.dylib"
;;
*.deb)
extract_dylib_from_deb "$filepath" "$tweak_id"
;;
*.framework.zip)
extract_framework_from_zip "$filepath" "$tweak_id"
;;
*.zip)
extract_from_zip "$filepath" "$tweak_id"
;;
*)
die "Unsupported asset type: ${filename}"
;;
esac
}
# Extract .dylib from a .deb package
# Deb structure: data.tar.* contains the actual files
extract_dylib_from_deb() {
local deb_path="$1" tweak_id="$2"
local extract_dir="${TWEAKS_DIR}/${tweak_id}/deb_extract"
mkdir -p "$extract_dir"
log_info " → Extracting .deb for ${tweak_id}"
# ar extracts the deb; data.tar.* contains the payload
(cd "$extract_dir" && ar -x "$deb_path" 2>/dev/null) || {
# Some debs are just tarballs
tar -xf "$deb_path" -C "$extract_dir" 2>/dev/null || die "Cannot extract deb: $deb_path"
}
# Extract data.tar.* (could be .gz, .xz, .lzma, .zst, or uncompressed)
local data_tar
data_tar=$(find "$extract_dir" -name "data.tar*" -type f | head -n 1)
[ -n "$data_tar" ] || die "No data.tar found in deb for ${tweak_id}"
tar -xf "$data_tar" -C "$extract_dir" 2>/dev/null || die "Failed to extract data.tar for ${tweak_id}"
# Look for .dylib files
local dylib
dylib=$(find "$extract_dir" -name "*.dylib" -type f | head -n 1)
if [ -n "$dylib" ]; then
cp "$dylib" "${DYLIBS_DIR}/${tweak_id}.dylib"
log_ok " → Extracted dylib: ${tweak_id}.dylib"
else
# Look for .framework instead
local fw
fw=$(find "$extract_dir" -name "*.framework" -type d | head -n 1)
if [ -n "$fw" ]; then
cp -R "$fw" "${FRAMEWORKS_DIR}/"
log_ok " → Extracted framework: $(basename "$fw")"
else
die "No .dylib or .framework found in deb for ${tweak_id}"
fi
fi
# Also look for .bundle resources (some tweaks ship localization bundles)
local bundle
bundle=$(find "$extract_dir" -name "*.bundle" -type d | head -n 1)
if [ -n "$bundle" ]; then
cp -R "$bundle" "${WORK_DIR}/bundles_extra/$(basename "$bundle")" 2>/dev/null || {
mkdir -p "${WORK_DIR}/bundles_extra"
cp -R "$bundle" "${WORK_DIR}/bundles_extra/"
}
log_ok " → Extracted bundle: $(basename "$bundle")"
fi
}
# Extract .framework from a zip
extract_framework_from_zip() {
local zip_path="$1" tweak_id="$2"
local extract_dir="${TWEAKS_DIR}/${tweak_id}/fw_extract"
mkdir -p "$extract_dir"
unzip -qo "$zip_path" -d "$extract_dir" || die "Failed to unzip framework for ${tweak_id}"
local fw
fw=$(find "$extract_dir" -name "*.framework" -type d | head -n 1)
[ -n "$fw" ] || die "No .framework found in zip for ${tweak_id}"
cp -R "$fw" "${FRAMEWORKS_DIR}/"
log_ok " → Extracted framework: $(basename "$fw")"
}
# Generic zip extraction — look for .dylib, .framework, etc.
extract_from_zip() {
local zip_path="$1" tweak_id="$2"
local extract_dir="${TWEAKS_DIR}/${tweak_id}/zip_extract"
mkdir -p "$extract_dir"
unzip -qo "$zip_path" -d "$extract_dir" || die "Failed to unzip for ${tweak_id}"
# Try .dylib first, then .framework
local dylib
dylib=$(find "$extract_dir" -name "*.dylib" -type f | head -n 1)
if [ -n "$dylib" ]; then
cp "$dylib" "${DYLIBS_DIR}/${tweak_id}.dylib"
log_ok " → Extracted dylib from zip: ${tweak_id}.dylib"
return
fi
local fw
fw=$(find "$extract_dir" -name "*.framework" -type d | head -n 1)
if [ -n "$fw" ]; then
cp -R "$fw" "${FRAMEWORKS_DIR}/"
log_ok " → Extracted framework from zip: $(basename "$fw")"
return
fi
die "No usable artifact found in zip for ${tweak_id}"
}
###############################################################################
# Fetch all enabled tweaks
###############################################################################
fetch_all_tweaks() {
local tweaks_json
tweaks_json=$(config_enabled_tweaks_json)
local count
count=$(echo "$tweaks_json" | jq 'length')
log_info "Fetching ${count} enabled tweak(s)…"
for i in $(seq 0 $((count - 1))); do
local tweak
tweak=$(echo "$tweaks_json" | jq -r ".[$i]")
local id repo fetch_method branch build_cmd
id=$(echo "$tweak" | jq -r '.id')
repo=$(echo "$tweak" | jq -r '.repo')
fetch_method=$(echo "$tweak" | jq -r '.fetch')
branch=$(echo "$tweak" | jq -r '.branch // empty')
build_cmd=$(echo "$tweak" | jq -r '.build_cmd // empty')
case "$fetch_method" in
release)
fetch_release_tweak "$repo" "$id"
;;
build)
fetch_build_tweak "$repo" "$id" "$branch" "$build_cmd"
;;
*)
die "Unknown fetch method '${fetch_method}' for tweak ${id}"
;;
esac
done
log_ok "All tweaks fetched"
}
###############################################################################
# IPA patching — inject dylibs & frameworks
###############################################################################
inject_tweaks() {
[ -d "$APP_DIR" ] || die "APP_DIR not set — call extract_ipa first"
local injector=""
if command -v optool &>/dev/null; then
injector="optool"
elif command -v insert_dylib &>/dev/null; then
injector="insert_dylib"
else
die "Neither optool nor insert_dylib found"
fi
# Determine the main executable name from Info.plist
local executable
executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "${APP_DIR}/Info.plist" 2>/dev/null \
|| python3 -c "
import plistlib
with open('${APP_DIR}/Info.plist', 'rb') as f:
print(plistlib.load(f)['CFBundleExecutable'])
")
local exec_path="${APP_DIR}/${executable}"
[ -f "$exec_path" ] || die "Executable not found: ${exec_path}"
# Create Frameworks directory inside the app if needed
mkdir -p "${APP_DIR}/Frameworks"
# Inject each .dylib
local dylib_count=0
for dylib in "${DYLIBS_DIR}"/*.dylib; do
[ -f "$dylib" ] || continue
local dylib_name
dylib_name=$(basename "$dylib")
log_info "Injecting ${dylib_name}"
cp "$dylib" "${APP_DIR}/Frameworks/${dylib_name}"
case "$injector" in
optool)
optool install -c load -p "@rpath/${dylib_name}" -t "$exec_path" \
|| die "optool injection failed for ${dylib_name}"
;;
insert_dylib)
insert_dylib --inplace --no-strip-codesig \
"@rpath/${dylib_name}" "$exec_path" \
|| die "insert_dylib injection failed for ${dylib_name}"
;;
esac
dylib_count=$((dylib_count + 1))
done
# Copy frameworks
for fw in "${FRAMEWORKS_DIR}"/*.framework; do
[ -d "$fw" ] || continue
local fw_name
fw_name=$(basename "$fw")
log_info "Embedding framework: ${fw_name}"
cp -R "$fw" "${APP_DIR}/Frameworks/"
# Inject the framework binary
local fw_binary="${fw_name%.framework}"
case "$injector" in
optool)
optool install -c load -p "@rpath/${fw_name}/${fw_binary}" -t "$exec_path" \
|| log_warn "optool injection for framework ${fw_name} returned non-zero (may already be loaded)"
;;
insert_dylib)
insert_dylib --inplace --no-strip-codesig \
"@rpath/${fw_name}/${fw_binary}" "$exec_path" \
|| log_warn "insert_dylib for framework ${fw_name} returned non-zero"
;;
esac
done
# Copy any extra bundles (e.g. localization bundles from tweaks)
if [ -d "${WORK_DIR}/bundles_extra" ]; then
for bundle in "${WORK_DIR}/bundles_extra"/*.bundle; do
[ -d "$bundle" ] || continue
log_info "Embedding bundle: $(basename "$bundle")"
cp -R "$bundle" "${APP_DIR}/"
done
fi
log_ok "Injected ${dylib_count} dylib(s) into ${executable}"
}
###############################################################################
# IPA customization — apply settings from config.yml
###############################################################################
apply_customization() {
[ -d "$APP_DIR" ] || die "APP_DIR not set"
local bundle_id display_name min_ios
local strip_watch strip_plugins strip_extensions
bundle_id=$(config_custom "bundle_id")
display_name=$(config_custom "display_name")
min_ios=$(config_custom "min_ios")
strip_watch=$(config_custom "strip_watch_extension")
strip_plugins=$(config_custom "strip_plugins")
strip_extensions=$(config_custom "strip_extensions")
local plist="${APP_DIR}/Info.plist"
# Use python3 for plist manipulation (cross-platform)
python3 - "$plist" "$bundle_id" "$display_name" "$min_ios" <<'PYEOF'
import plistlib, sys
plist_path, bundle_id, display_name, min_ios = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
with open(plist_path, 'rb') as f:
plist = plistlib.load(f)
if bundle_id:
plist['CFBundleIdentifier'] = bundle_id
if display_name:
plist['CFBundleDisplayName'] = display_name
plist['CFBundleName'] = display_name
if min_ios:
plist['MinimumOSVersion'] = min_ios
# Remove UISupportedDevices to allow sideloading on any device
plist.pop('UISupportedDevices', None)
with open(plist_path, 'wb') as f:
plistlib.dump(plist, f)
PYEOF
log_ok "Updated plist: bundle_id=${bundle_id}, name=${display_name}, min_ios=${min_ios}"
# Remove code signature (required for re-signing after modification)
rm -rf "${APP_DIR}/_CodeSignature"
log_ok "Removed _CodeSignature"
# Strip watch extension
if [ "$strip_watch" = "True" ] || [ "$strip_watch" = "true" ]; then
rm -rf "${APP_DIR}/Watch"
log_ok "Stripped Watch extension"
fi
# Strip plugins
if [ "$strip_plugins" = "True" ] || [ "$strip_plugins" = "true" ]; then
rm -rf "${APP_DIR}/PlugIns"
log_ok "Stripped PlugIns"
fi
# Strip app extensions
if [ "$strip_extensions" = "True" ] || [ "$strip_extensions" = "true" ]; then
# Remove all .appex except ones we explicitly embed
if [ -d "${APP_DIR}/PlugIns" ]; then
find "${APP_DIR}/PlugIns" -name "*.appex" -type d -exec rm -rf {} + 2>/dev/null || true
fi
log_ok "Stripped extensions"
fi
# Apply custom icon if specified and file exists
local icon_path
icon_path=$(config_custom "icon")
if [ -n "$icon_path" ] && [ -f "${SCRIPT_DIR}/${icon_path}" ]; then
# Copy icon file over existing AppIcon assets
log_info "Custom icon specified: ${icon_path}"
# Note: Proper icon replacement requires multiple sizes; this is a placeholder
# for a full icon replacement implementation
fi
}
###############################################################################
# Repackage IPA
###############################################################################
# $1 = output path (default: YouTube-patched.ipa)
repackage_ipa() {
local output="${1:-${SCRIPT_DIR}/YouTube-patched.ipa}"
log_info "Repackaging IPA…"
(cd "${PAYLOAD_DIR}" && zip -qry "${output}" Payload/) \
|| die "Failed to create output IPA"
local size
size=$(du -h "$output" | cut -f1)
log_ok "Output IPA: ${output} (${size})"
# Print SHA256 for verification
if command -v shasum &>/dev/null; then
echo -e "${BOLD}SHA256: $(shasum -a 256 "$output" | cut -d' ' -f1)${NC}"
elif command -v sha256sum &>/dev/null; then
echo -e "${BOLD}SHA256: $(sha256sum "$output" | cut -d' ' -f1)${NC}"
fi
}
###############################################################################
# Full pipeline — called by build.sh and the CI workflow
###############################################################################
# $1 = IPA source (path or URL)
# $2 = output IPA path (optional)
run_full_pipeline() {
local ipa_source="$1"
local output_ipa="${2:-${SCRIPT_DIR}/YouTube-patched.ipa}"
log_info "YTLitePlus Build Pipeline"
echo "──────────────────────────────────────"
check_deps
prepare_workspace
extract_ipa "$ipa_source"
fetch_all_tweaks
inject_tweaks
apply_customization
repackage_ipa "$output_ipa"
log_ok "Build complete!"
echo "──────────────────────────────────────"
}