From 6eeac05d8cefb6fbe706fcadac4a16f4e7ecdc25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:16:08 +0000 Subject: [PATCH] Add v2 redesign: config.yml, lib.sh, build.sh, and GitHub Actions workflow Co-authored-by: Balackburn <93828569+Balackburn@users.noreply.github.com> --- .github/workflows/build.yml | 104 +++++++ .gitignore | 3 + build.sh | 71 +++-- config.yml | 89 ++++++ lib.sh | 606 ++++++++++++++++++++++++++++++++++++ 5 files changed, 842 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/build.yml mode change 100644 => 100755 build.sh create mode 100644 config.yml create mode 100755 lib.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..106ecc4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,104 @@ +# build.yml — GitHub Actions workflow for YTLitePlus v2 +# +# Shares all core logic with build.sh via lib.sh. +# config.yml is the single source of truth for tweaks. + +name: Build YTLitePlus (v2) + +on: + workflow_dispatch: + inputs: + ipa_url: + description: "Direct URL to decrypted YouTube IPA (falls back to IPA_URL secret)" + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build patched IPA + runs-on: macos-latest + permissions: + contents: write + + steps: + # ── Checkout ────────────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + + # ── Dependencies ────────────────────────────────────────── + - name: Install system dependencies + run: | + brew install jq + pip3 install pyyaml + + - name: Install optool + run: | + # Build optool from source for dylib injection + git clone --depth=1 https://github.com/alexzielenski/optool.git /tmp/optool + cd /tmp/optool + git submodule update --init --recursive + xcodebuild -project optool.xcodeproj -scheme optool \ + -configuration Release SYMROOT=/tmp/optool/build \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + sudo cp /tmp/optool/build/Release/optool /usr/local/bin/optool + + # ── Resolve IPA URL ─────────────────────────────────────── + - name: Resolve IPA URL + id: ipa + run: | + URL="${{ inputs.ipa_url }}" + if [ -z "$URL" ]; then + URL="${{ secrets.IPA_URL }}" + fi + if [ -z "$URL" ]; then + echo "::error::No IPA URL provided. Pass it as a workflow input or set the IPA_URL repository secret." + exit 1 + fi + # Mask the URL in logs + echo "::add-mask::${URL}" + echo "url=${URL}" >> "$GITHUB_OUTPUT" + + # ── Build ───────────────────────────────────────────────── + - name: Run build pipeline + run: | + chmod +x build.sh lib.sh + ./build.sh "${{ steps.ipa.outputs.url }}" + + # ── Release ─────────────────────────────────────────────── + - name: Get version info + id: version + run: | + # Extract YouTube version from the patched IPA for the release tag + VERSION_TAG="v2-$(date +'%Y%m%d-%H%M%S')" + echo "tag=${VERSION_TAG}" >> "$GITHUB_OUTPUT" + SHA256=$(shasum -a 256 YouTube-patched.ipa | cut -d' ' -f1 || sha256sum YouTube-patched.ipa | cut -d' ' -f1) + echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: "YTLitePlus ${{ steps.version.outputs.tag }}" + body: | + ## YTLitePlus — Automated Build + + **SHA256:** `${{ steps.version.outputs.sha256 }}` + + Built from commit ${{ github.sha }}. + files: YouTube-patched.ipa + draft: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── Summary ─────────────────────────────────────────────── + - name: Job Summary + run: | + echo '### 📺 YTLitePlus Build Complete' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo "**SHA256:** \`${{ steps.version.outputs.sha256 }}\`" >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo 'A draft release has been created. Review and publish it from the [Releases page](../../releases).' >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 1af2e20..ebf8b24 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ Resources/ *.ipa .vscode _codeql_detected_source_root + +# v2 build system work directory +_work/ diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 5d55ce9..aa26bfe --- a/build.sh +++ b/build.sh @@ -1,37 +1,46 @@ -#!/bin/bash -# To build, either place the IPA file in the project's root directory, or get the path to the IPA, then run `./build.sh` +#!/usr/bin/env bash +# build.sh — Local build script for YTLitePlus +# +# Usage: +# ./build.sh /path/to/YouTube.ipa # explicit IPA path +# ./build.sh https://example.com/YT.ipa # download from URL +# ./build.sh # auto-detect IPA in current dir +# +# Reads config.yml for tweak list and customization settings. +# Shares all core logic with the CI workflow via lib.sh. -read -p $'\e[34m==> \e[1;39mPath to the decrypted YouTube.ipa or YouTube.app. If nothing is provied, any ipa/app in the project\'s root directory will be used: ' PATHTOYT +set -euo pipefail -# Check if PATHTOYT is empty -if [ -z "$PATHTOYT" ]; then - # Look for ipa/app files in the current directory - IPAS=$(find . -maxdepth 1 -type f \( -name "*.ipa" -o -name "*.app" \)) - - # Check if there are two or more ipa/app files - COUNT=$(echo "$IPAS" | wc -l) - - if [ "$COUNT" -ge 2 ]; then - echo "❌ Error: Multiple IPA/app files found in the project's root directory directory. Make sure there is only one ipa." - exit 1 - - elif [ -n "$IPAS" ]; then - PATHTOYT=$(echo "$IPAS" | head -n 1) - - else - echo "❌ Error: No IPA/app file found in the project's root directory directory." - exit 1 - fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +############################################################################### +# Resolve the IPA source +############################################################################### +IPA_SOURCE="${1:-}" + +if [ -z "$IPA_SOURCE" ]; then + # Auto-detect: look for a single .ipa in the project root + mapfile -t ipa_files < <(find "$SCRIPT_DIR" -maxdepth 1 -name "*.ipa" -type f) + + if [ ${#ipa_files[@]} -eq 0 ]; then + die "No IPA file provided and none found in ${SCRIPT_DIR}.\nUsage: ./build.sh /path/to/YouTube.ipa" + elif [ ${#ipa_files[@]} -gt 1 ]; then + die "Multiple IPA files found in ${SCRIPT_DIR}. Provide the path explicitly.\nUsage: ./build.sh /path/to/YouTube.ipa" + fi + + IPA_SOURCE="${ipa_files[0]}" + log_info "Auto-detected IPA: ${IPA_SOURCE}" fi -make package THEOS_PACKAGE_SCHEME=rootless IPA="$PATHTOYT" FINALPACKAGE=1 +############################################################################### +# Run the pipeline +############################################################################### +OUTPUT_IPA="${SCRIPT_DIR}/YouTube-patched.ipa" -# SHASUM -if [[ $? -eq 0 ]]; then - open packages - echo "SHASUM256: $(shasum -a 256 packages/*.ipa)" +run_full_pipeline "$IPA_SOURCE" "$OUTPUT_IPA" -else - echo "Failed building YTLitePlus" - -fi \ No newline at end of file +# Cleanup intermediate work directory +cleanup_workspace \ No newline at end of file diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..45179f1 --- /dev/null +++ b/config.yml @@ -0,0 +1,89 @@ +# config.yml — Single source of truth for YTLitePlus +# +# To add a new tweak: +# 1. Add a new entry under "tweaks:" with id, repo, fetch method, and enabled flag. +# 2. For release-based tweaks: set fetch: release +# 3. For source-build tweaks: set fetch: build, and optionally branch/build_cmd +# 4. Set always: true if the tweak must always be injected (e.g. the primary tweak) +# 5. That's it — no other files need to change. + +tweaks: + - id: ytlite + enabled: true + repo: dayanch96/YTLite + fetch: release + always: true + + - id: youpip + enabled: true + repo: PoomSmart/YouPiP + fetch: release + + - id: ytuhd + enabled: true + repo: splaser/YTUHD + fetch: release + + - id: ytabconfig + enabled: true + repo: PoomSmart/YTABConfig + fetch: release + + - id: return_yt_dislikes + enabled: true + repo: PoomSmart/Return-YouTube-Dislikes + fetch: release + + - id: dont_eat_my_content + enabled: true + repo: therealFoxster/DontEatMyContent + fetch: release + + - id: ytvideooverlay + enabled: true + repo: PoomSmart/YTVideoOverlay + fetch: release + + - id: yougroupsettings + enabled: true + repo: PoomSmart/YouGroupSettings + fetch: release + + - id: alderis + enabled: true + repo: hbang/Alderis + fetch: release + + - id: flexing + enabled: false + repo: PoomSmart/FLEXing + fetch: build + branch: rootless + build_cmd: make package THEOS_PACKAGE_SCHEME=rootless + + - id: youtimestamp + enabled: false + repo: aricloverALT/YouTimeStamp + fetch: build + build_cmd: make package + + - id: ytheaders + enabled: false + repo: therealFoxster/YTHeaders + fetch: build + build_cmd: make package + + - id: open_youtube_safari + enabled: false + repo: BillyCurtis/OpenYouTubeSafariExtension + fetch: build + build_cmd: make package + +customization: + bundle_id: com.custom.youtube + display_name: "YouTube+" + icon: assets/icon.png + min_ios: "16.0" + strip_watch_extension: true + strip_plugins: true + strip_extensions: true diff --git a/lib.sh b/lib.sh new file mode 100755 index 0000000..615f6c7 --- /dev/null +++ b/lib.sh @@ -0,0 +1,606 @@ +#!/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 "──────────────────────────────────────" +}