#!/usr/bin/env bash # Spot-Lyric for Spotify — patches the Spotify desktop client (macOS / Linux) with a # third-party lyrics page (NetEase / QQ Music, matched on this machine). # Windows: patch.cmd / patch.ps1 — same commands, options and guided steps. # # Usage modes (asked step by step, or --mode): # cloud lyrics server stores shared matches (default); NetEase / QQ requests (--request) # go direct (--disable-web-security, Linux) or through the lyrics server relay # local pure local: the local service (spot-lyric-server on 127.0.0.1:38917, # downloaded / built only for this mode) relays NetEase / QQ requests # direct pure local without the service: --disable-web-security (advanced, Linux only) # The plugin always prefers local paths: direct -> local service -> lyrics server. # # Portable to the bash 3.2 that ships with macOS: no readlink -f, getent, # setsid, associative arrays, mapfile or ${x,,}. set -Eeuo pipefail REPO=dibin666/spotify-patch-lryic ROOT="" if [[ -n ${BASH_SOURCE[0]:-} && -f ${BASH_SOURCE[0]} ]]; then ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"; fi # ------------------------------------------------------------ bootstrap --- # Run without a checkout (curl ... | bash): fetch the sources, then run them. if [[ -z $ROOT || ! -f $ROOT/src/app.js ]]; then tmp="$(mktemp -d)" # Only the scripts and the plugin sources (a few hundred KB); the local service program is # fetched later and only for the pure local mode. printf '\033[1;32m[spot-lyric]\033[0m 获取最新的补丁脚本与歌词插件(github.com/%s)…\n' "$REPO" >&2 if command -v curl >/dev/null 2>&1; then curl -fsSL "https://github.com/$REPO/archive/refs/heads/main.tar.gz" | tar xz -C "$tmp" --strip-components=1 else wget -qO- "https://github.com/$REPO/archive/refs/heads/main.tar.gz" | tar xz -C "$tmp" --strip-components=1 fi [[ -f $tmp/patch.sh ]] || { echo "[spot-lyric] 下载失败" >&2; exit 1; } # Prompts need the terminal even though this script arrived through a pipe. if [[ ! -t 0 ]] && { : < /dev/tty; } 2>/dev/null; then exec bash "$tmp/patch.sh" "$@" < /dev/tty; fi exec bash "$tmp/patch.sh" "$@" fi VERSION="$(tr -d ' \r\n' < "$ROOT/VERSION" 2>/dev/null || echo 0.0.0)" DEFAULT_SERVER=https://spo.564616.xyz LOCAL_URL=http://127.0.0.1:38917 case "${SPOT_LYRIC_PLATFORM:-$(uname -s)}" in Darwin|macos) PLATFORM=macos ;; Linux|linux) PLATFORM=linux ;; *) echo "不支持的系统:$(uname -s)(Windows 请使用 patch.cmd)" >&2; exit 1 ;; esac # Overridable for tests (a sandbox must never see or change the real system hook). APT_HOOK_DIR="${SPOT_LYRIC_APT_HOOK_DIR:-/usr/local/share/spot-lyric-patch}" APT_HOOK_FILE="${SPOT_LYRIC_APT_HOOK_FILE:-/etc/apt/apt.conf.d/99spot-lyric-patch}" usage() { cat <&2; } die() { printf '%s %s\n' "$(paint '1;31' '[spot-lyric]')" "$*" >&2; exit 1; } # -------------------------------------------------------------- options --- # Long options may be written --name, -name or PowerShell style (-Server, -NoRestart). COMMAND=""; MODE=""; REQUEST=""; SERVER=""; SPOTIFY_PATH="${SPOTIFY_PATH:-}"; RESTART=auto; HOOK=""; YES=0 INTERNAL_APPS="" SERVER_RE='^https?://[A-Za-z0-9._~:/-]+$' while [[ $# -gt 0 ]]; do arg="$1"; shift if [[ $arg == __patcher ]]; then INTERNAL_APPS="${1:?}"; shift; INTERNAL_ARGS=("$@"); break; fi # privileged re-entry if [[ $arg != -* ]]; then [[ -z $COMMAND ]] || { usage >&2; die "多余的参数:$arg"; } case "$(printf '%s' "$arg" | tr 'A-Z' 'a-z')" in install|apply|restore|uninstall|status|hook|unhook) COMMAND="$(printf '%s' "$arg" | tr 'A-Z' 'a-z')" ;; help) usage; exit 0 ;; version) echo "$VERSION"; exit 0 ;; *) usage >&2; die "未知命令:$arg" ;; esac continue fi name="${arg#-}"; name="${name#-}"; value=""; has_value=0 if [[ $name == *=* ]]; then value="${name#*=}"; name="${name%%=*}"; has_value=1; fi key="$(printf '%s' "$name" | tr 'A-Z' 'a-z' | tr -d '_-')" take() { if [[ $has_value == 1 ]]; then return 0; fi; [[ $# -gt 0 ]] || die "$arg 需要一个值"; value="$1"; } case "$key" in mode) take "$@"; [[ $has_value == 1 ]] || shift case "$(printf '%s' "$value" | tr 'A-Z' 'a-z')" in cloud|server|remote) MODE=cloud ;; local|service) MODE=local ;; direct) MODE=direct ;; *) die "未知的使用方式:${value}(cloud / local / direct)" ;; esac ;; request) take "$@"; [[ $has_value == 1 ]] || shift case "$(printf '%s' "$value" | tr 'A-Z' 'a-z')" in service|local) REQUEST=service ;; direct) REQUEST=direct ;; server|relay|remote|none) REQUEST=server ;; *) die "未知的请求方式:${value}(service / direct / server)" ;; esac ;; server) take "$@"; [[ $has_value == 1 ]] || shift SERVER="${value%/}" [[ $SERVER =~ $SERVER_RE ]] || die "歌词服务器地址无效:${SERVER}(例如 https://lyrics.example.com)" ;; spotifypath) take "$@"; [[ $has_value == 1 ]] || shift; SPOTIFY_PATH="$value" ;; cloud) MODE=cloud ;; nodirect) REQUEST=server ;; local) MODE=local ;; direct) MODE=direct ;; restart) RESTART=yes ;; norestart) RESTART=no ;; hook) HOOK=on ;; nohook) HOOK=off ;; yes|y) YES=1 ;; quiet|q) QUIET=1 ;; help|h) usage; exit 0 ;; *) usage >&2; die "未知参数:$arg" ;; esac done COMMAND="${COMMAND:-install}" # --request is the cloud mode's choice; "service" means the pure local mode. case "$MODE:$REQUEST" in :service|local:service) MODE=local; REQUEST="" ;; cloud:service) die "本地服务只用于纯本地模式(--mode local);云端模式的 --request 可选 direct / server" ;; local:*?) die "纯本地模式(--mode local)使用本地服务,不需要 --request" ;; direct:server) die "--mode direct 是不连接服务器的纯本地模式,不能配合 --request server" ;; direct:direct) REQUEST="" ;; esac # ----------------------------------------------------------------- user --- # User-level pieces (config, local service, launcher entries, Spotify restart) belong to # the desktop user even when the script runs under sudo. TARGET_USER="${SUDO_USER:-$(id -un)}" TARGET_UID="$(id -u "$TARGET_USER")" home_of() { local h="" command -v getent >/dev/null 2>&1 && h="$(getent passwd "$1" | cut -d: -f6)" [[ -z $h && $PLATFORM == macos ]] && h="$(dscl . -read "/Users/$1" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" [[ -z $h ]] && h="$(eval echo "~$1")" printf '%s' "$h" } # Without sudo $HOME is authoritative (also keeps test runs with a throw-away HOME contained). if [[ -z ${SUDO_USER:-} && -n ${HOME:-} ]]; then TARGET_HOME="$HOME"; else TARGET_HOME="$(home_of "$TARGET_USER")"; fi as_user() { if [[ $(id -u) == "$TARGET_UID" ]]; then "$@" elif [[ $PLATFORM == linux ]]; then sudo -u "$TARGET_USER" XDG_RUNTIME_DIR="/run/user/$TARGET_UID" \ DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$TARGET_UID/bus" HOME="$TARGET_HOME" "$@" else sudo -u "$TARGET_USER" HOME="$TARGET_HOME" "$@" fi } # Root for system files (apt hook, re-signing): one notice and one password prompt per run. SUDO_READY=0 need_root() { # need_root REASON [[ $(id -u) == 0 || $SUDO_READY == 1 ]] && return 0 say "需要管理员权限(sudo):$1" sudo -v || die "没有获得管理员权限" SUDO_READY=1 } privileged() { if [[ $(id -u) == 0 ]]; then "$@" else need_root "修改系统文件"; sudo "$@" fi } sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -c1-64; else shasum -a 256 | cut -c1-64; fi; } http_get() { # http_get URL [timeout] -> body (empty on failure) if command -v curl >/dev/null 2>&1; then curl -fsS -m "${2:-8}" "$1" 2>/dev/null || true elif command -v wget >/dev/null 2>&1; then wget -qO- -T "${2:-8}" "$1" 2>/dev/null || true fi } json_version() { sed -n 's/.*"version": *"\([^"]*\)".*/\1/p'; } # Per-user data: config (last choices), the local service program and its log. if [[ $PLATFORM == macos ]]; then DATA_DIR="$TARGET_HOME/Library/Application Support/SpotLyric" AGENT_DIR="$TARGET_HOME/Library/LaunchAgents" else DATA_DIR="${XDG_DATA_HOME:-$TARGET_HOME/.local/share}/spot-lyric" CONFIG_HOME="${XDG_CONFIG_HOME:-$TARGET_HOME/.config}" UNIT_DIR="$CONFIG_HOME/systemd/user" fi [[ -n ${SPOT_LYRIC_DATA:-} ]] && DATA_DIR="$SPOT_LYRIC_DATA" CONFIG_FILE="$DATA_DIR/config" CFG_MODE=""; CFG_SERVER=""; CFG_REQUEST="" if [[ -f $CONFIG_FILE ]]; then CFG_MODE="$(sed -n 's/^mode=//p' "$CONFIG_FILE" | head -n1)" CFG_SERVER="$(sed -n 's/^server=//p' "$CONFIG_FILE" | head -n1)" CFG_REQUEST="$(sed -n 's/^request=//p' "$CONFIG_FILE" | head -n1)" case "$CFG_MODE" in cloud|local|direct) ;; *) CFG_MODE="" ;; esac case "$CFG_REQUEST" in service|direct|server) ;; *) CFG_REQUEST="" ;; esac [[ $CFG_SERVER =~ $SERVER_RE ]] || CFG_SERVER="" fi save_config() { # save_config MODE SERVER REQUEST as_user mkdir -p "$DATA_DIR" { printf 'mode=%s\nrequest=%s\n' "$1" "$3"; [[ $2 == "$DEFAULT_SERVER" ]] || printf 'server=%s\n' "$2"; } | as_user tee "$CONFIG_FILE" >/dev/null } mode_name() { case "$1" in cloud) echo "云端服务器" ;; local) echo "纯本地 · 本地服务" ;; direct) echo "纯本地 · 直连" ;; esac; } request_name() { case "$1" in service) echo "本地服务" ;; direct) echo "直连" ;; server) echo "经歌词服务器转发" ;; esac; } # --------------------------------------------------------------- detect --- # Sets SPOTIFY_DIR (install / .app root) and APPS (directory holding xpui.spa). apps_of() { if [[ $PLATFORM == macos ]]; then printf '%s/Contents/Resources/Apps' "$1"; else printf '%s/Apps' "$1"; fi } valid_install() { local a; a="$(apps_of "$1")"; [[ -n "$1" && ( -f "$a/xpui.spa" || -f "$a/xpui/index.html" ) ]]; } detect_spotify() { local c [[ -n ${SPOTIFY_DIR:-} ]] && return 0 if [[ -n $SPOTIFY_PATH ]]; then c="${SPOTIFY_PATH%/}" if [[ $PLATFORM == macos ]]; then case "$c" in */Contents/Resources) c="${c%/Contents/Resources}" ;; */Contents) c="${c%/Contents}" ;; esac fi valid_install "$c" || die "指定位置不是 Spotify:$SPOTIFY_PATH" SPOTIFY_DIR="$(cd "$c" && pwd -P)"; APPS="$(apps_of "$SPOTIFY_DIR")"; return 0 fi if [[ $PLATFORM == macos ]]; then for c in "$TARGET_HOME/Applications/Spotify.app" "/Applications/Spotify.app"; do valid_install "$c" && { SPOTIFY_DIR="$c"; APPS="$(apps_of "$c")"; return 0; } done return 1 fi if command -v spotify >/dev/null 2>&1; then c="$(dirname "$(readlink -f "$(command -v spotify)")")" valid_install "$c" && { SPOTIFY_DIR="$c"; APPS="$(apps_of "$c")"; return 0; } fi if command -v dpkg >/dev/null 2>&1; then c="$(dpkg -L spotify-client 2>/dev/null | grep -m1 '/Apps/xpui.spa$' || true)" [[ -n $c ]] && { SPOTIFY_DIR="${c%/Apps/xpui.spa}"; APPS="$(apps_of "$SPOTIFY_DIR")"; return 0; } fi for c in /usr/share/spotify /opt/spotify /usr/lib/spotify /usr/local/share/spotify /usr/lib64/spotify-client \ "$TARGET_HOME/.local/share/spotify" \ "$TARGET_HOME/.local/share/flatpak/app/com.spotify.Client/current/active/files/extra/share/spotify" \ /var/lib/flatpak/app/com.spotify.Client/current/active/files/extra/share/spotify \ /snap/spotify/current/usr/share/spotify; do valid_install "$c" && { SPOTIFY_DIR="$(readlink -f "$c")"; APPS="$(apps_of "$SPOTIFY_DIR")"; return 0; } done return 1 } need_spotify() { detect_spotify || { [[ $PLATFORM == macos ]] && die "没有找到 Spotify.app,请用 --spotify-path 指定(例如 /Applications/Spotify.app)" die "没有找到 Spotify,请用 --spotify-path 指定(含 Apps/xpui.spa 的目录)" } } spotify_version() { local v="" if [[ $PLATFORM == macos ]]; then v="$(defaults read "$SPOTIFY_DIR/Contents/Info" CFBundleShortVersionString 2>/dev/null || plutil -extract CFBundleShortVersionString raw "$SPOTIFY_DIR/Contents/Info.plist" 2>/dev/null || true)" else if command -v dpkg >/dev/null 2>&1 && dpkg -S "$APPS/xpui.spa" >/dev/null 2>&1; then v="$(dpkg-query -W -f='${Version}' spotify-client 2>/dev/null || true)" fi [[ -z $v && -x "$SPOTIFY_DIR/spotify" ]] && v="$( (strings "$SPOTIFY_DIR/spotify" 2>/dev/null || true) | grep -m1 -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\.g[0-9a-f]+' || true)" fi printf '%s' "${v:-unknown}" } # ---------------------------------------------------------------- build --- # The usage mode and both addresses are baked into the plugin. build_bundle() { # build_bundle OUT_DIR MODE SERVER REQUEST local out="$1" mkdir -p "$out" { printf '/* Spot-Lyric for Spotify v%s - generated, do not edit */\n' "$VERSION" cat "$ROOT/src/core.js" printf '\n' sed -e "s/__SPOT_LYRIC_VERSION__/$VERSION/g" -e "s|__SPOT_LYRIC_SERVER__|$3|g" \ -e "s/__SPOT_LYRIC_MODE__/$2/g" -e "s/__SPOT_LYRIC_REQUEST__/$4/g" -e "s|__SPOT_LYRIC_LOCAL__|$LOCAL_URL|g" "$ROOT/src/app.js" } > "$out/spot-lyric.js" cp "$ROOT/src/app.css" "$out/spot-lyric.css" } # ----------------------------------------------------- patcher (pure sh) --- # xpui.spa is a zip archive. It is read and rewritten with base tools only (od, awk, # gzip, head, tail): a zip "deflate" entry stores exactly the raw deflate stream and # CRC-32 that gzip produces, so no python / zip / unzip is needed. A rewrite keeps all # original entry data as is, appends the new index.html and plugin files and writes a # new central directory without the replaced entries. Restoring copies the untouched # backup back. Same layout, marker and digest as patch.ps1. ZP_BLOCK_RE='.*' zp_marker() { sed -n 's/.*.*/\1 \2/p' | head -n1; } zp_inject() { # stdin html, $1 digest local block="" local html; html="$(sed -E "s#$ZP_BLOCK_RE##")" case "$html" in *""*) printf '%s' "${html%%*}$block${html#*}" ;; *) printf '%s%s' "$html" "$block" ;; esac } fsize() { local n; n="$(wc -c < "$1")"; echo $((n)); } zbytes() { ( set +o pipefail; tail -c +"$(( $2 + 1 ))" "$1" | head -c "$3" ); } # file offset length dec() { LC_ALL=C od -An -tu1 -v; } # Raw little-endian integers / bytes. raw() { local b f=""; for b in "$@"; do f="$f$(printf '\\0%03o' "$b")"; done; printf '%b' "$f"; } le16() { raw $(($1 & 255)) $(($1 >> 8 & 255)); } le32() { raw $(($1 & 255)) $(($1 >> 8 & 255)) $(($1 >> 16 & 255)) $(($1 >> 24 & 255)); } # zip_eocd FILE -> "count cd_size cd_offset" of the end-of-central-directory record. zip_eocd() { local size n; size="$(fsize "$1")"; n=$(( size < 65557 ? size : 65557 )) zbytes "$1" $((size - n)) "$n" | dec | LC_ALL=C awk ' { for (i = 1; i <= NF; i++) b[c++] = $i } END { for (p = c - 22; p >= 0; p--) if (b[p] == 80 && b[p+1] == 75 && b[p+2] == 5 && b[p+3] == 6) break if (p < 0) exit 1 count = b[p+10] + 256 * b[p+11] size = b[p+12] + 256 * (b[p+13] + 256 * (b[p+14] + 256 * b[p+15])) off = b[p+16] + 256 * (b[p+17] + 256 * (b[p+18] + 256 * b[p+19])) if (count == 65535 || off == 4294967295) exit 2 print count, size, off }' } # zip_central FILE CD_OFF CD_SIZE -> one line per entry: # start length method crc0 crc1 crc2 crc3 csize usize local_offset name zip_central() { zbytes "$1" "$2" "$3" | dec | LC_ALL=C awk ' function u16(p) { return b[p] + 256 * b[p+1] } function u32(p) { return b[p] + 256 * (b[p+1] + 256 * (b[p+2] + 256 * b[p+3])) } { for (i = 1; i <= NF; i++) b[c++] = $i } END { for (p = 0; p + 46 <= c && b[p] == 80 && b[p+1] == 75 && b[p+2] == 1 && b[p+3] == 2; p += len) { n = u16(p + 28); len = 46 + n + u16(p + 30) + u16(p + 32); name = "" for (i = 0; i < n; i++) name = name sprintf("%c", b[p + 46 + i]) print p, len, u16(p + 10), b[p+16], b[p+17], b[p+18], b[p+19], u32(p + 20), u32(p + 24), u32(p + 42), name } }' } # zip_read FILE NAME -> the entry contents on stdout (status 1 when missing). zip_read() { local e start len method c0 c1 c2 c3 csize usize lho name hdr e="$(zip_eocd "$1")" || return 1 set -- "$1" "$2" $e while read -r start len method c0 c1 c2 c3 csize usize lho name; do [[ $name == "$2" ]] || continue hdr="$(zbytes "$1" "$lho" 30 | dec | LC_ALL=C awk '{ for (i = 1; i <= NF; i++) b[c++] = $i } END { print b[26] + 256 * b[27] + b[28] + 256 * b[29] }')" case "$method" in 0) zbytes "$1" $((lho + 30 + hdr)) "$csize" ;; 8) { raw 31 139 8 0 0 0 0 0 0 255; zbytes "$1" $((lho + 30 + hdr)) "$csize"; raw "$c0" "$c1" "$c2" "$c3"; le32 "$usize"; } | gzip -dc ;; *) return 1 ;; esac return 0 done < <(zip_central "$1" "$5" "$4") return 1 } # zip_rewrite SOURCE TARGET HTML [JS CSS]: SOURCE without index.html and spot-lyric/*, # plus HTML as index.html and the plugin files; written next to TARGET, then swapped in. zip_rewrite() { local src="$1" target="$2" e count cdsize cdoff tmp cd keep start len method c0 c1 c2 c3 csize usize lho name local run_start=-1 run_end=0 entry file gz gsize off crc tm dt n e="$(zip_eocd "$src")" || { echo "xpui.spa: 无法读取 zip 目录(或为不支持的 ZIP64)"; return 1; } read -r count cdsize cdoff <<< "$e" tmp="$target.spot-lyric-$$"; cd="$tmp.cd" head -c "$cdoff" "$src" > "$tmp"; : > "$cd" keep=0 # Copy the kept central records in contiguous runs (dropped records are few). while read -r start len method c0 c1 c2 c3 csize usize lho name; do if [[ $name == index.html || $name == spot-lyric/* ]]; then [[ $run_start -ge 0 ]] && zbytes "$src" $((cdoff + run_start)) $((run_end - run_start)) >> "$cd" run_start=-1; continue fi [[ $run_start -lt 0 ]] && run_start=$start run_end=$((start + len)); keep=$((keep + 1)) done < <(zip_central "$src" "$cdoff" "$cdsize") [[ $run_start -ge 0 ]] && zbytes "$src" $((cdoff + run_start)) $((run_end - run_start)) >> "$cd" set -- $(date '+%Y %m %d %H %M %S') tm=$(( (10#$4 << 11) | (10#$5 << 5) | (10#$6 / 2) )); dt=$(( ((10#$1 - 1980) << 9) | (10#$2 << 5) | 10#$3 )) set -- "index.html=$ZR_HTML" ${ZR_JS:+"spot-lyric/spot-lyric.js=$ZR_JS"} ${ZR_CSS:+"spot-lyric/spot-lyric.css=$ZR_CSS"} for entry in "$@"; do name="${entry%%=*}"; file="${entry#*=}" gz="$tmp.gz"; gzip -n -c < "$file" > "$gz"; gsize="$(fsize "$gz")"; csize=$((gsize - 18)) crc="$(zbytes "$gz" $((gsize - 8)) 4 | dec)"; usize="$(fsize "$file")"; off="$(fsize "$tmp")"; n=${#name} { raw 80 75 3 4; le16 20; le16 0; le16 8; le16 "$tm"; le16 "$dt"; raw $crc; le32 "$csize"; le32 "$usize"; le16 "$n"; le16 0; printf '%s' "$name" zbytes "$gz" 10 "$csize"; } >> "$tmp" { raw 80 75 1 2; le16 20; le16 20; le16 0; le16 8; le16 "$tm"; le16 "$dt"; raw $crc; le32 "$csize"; le32 "$usize"; le16 "$n" le16 0; le16 0; le16 0; le16 0; le32 0; le32 "$off"; printf '%s' "$name"; } >> "$cd" keep=$((keep + 1)); rm -f "$gz" done off="$(fsize "$tmp")"; cdsize="$(fsize "$cd")" cat "$cd" >> "$tmp"; rm -f "$cd" { raw 80 75 5 6; le16 0; le16 0; le16 "$keep"; le16 "$keep"; le32 "$cdsize"; le32 "$off"; le16 0; } >> "$tmp" # Keep the owner / mode of the archive it replaces (root-owned installs stay readable). chmod "$(stat -c %a "$target" 2>/dev/null || stat -f %Lp "$target" 2>/dev/null || echo 644)" "$tmp" if [[ $(id -u) == 0 ]]; then chown "$(stat -c %u:%g "$target" 2>/dev/null || stat -f %u:%g "$target")" "$tmp" 2>/dev/null || true; fi mv -f "$tmp" "$target" } xpui() { # xpui APPS [js css] local cmd="$1" apps="$2" folder spa backup html current digest stage folder="$apps/xpui"; spa="$apps/xpui.spa"; backup="$spa.spot-lyric.bak" case "$cmd" in patch) local js="$3" css="$4" digest="$( { cat "$js"; printf '\0'; cat "$css"; } | sha256 | cut -c1-16)" if [[ -f "$folder/index.html" ]]; then current="$(zp_marker < "$folder/index.html")" [[ $current == "$VERSION $digest" ]] && { echo "UNCHANGED dir"; return 0; } mkdir -p "$folder/spot-lyric" cp "$js" "$css" "$folder/spot-lyric/" html="$(zp_inject "$digest" < "$folder/index.html")" printf '%s' "$html" > "$folder/index.html" echo "PATCHED dir $folder"; return 0 fi [[ -f $spa ]] || { echo "xpui.spa not found in $apps"; return 1; } current="$(zip_read "$spa" index.html | zp_marker)" [[ $current == "$VERSION $digest" ]] && { echo "UNCHANGED spa"; return 0; } if [[ -z $current ]]; then cp -p "$spa" "$backup" # fresh / freshly updated archive = new original else [[ -f $backup ]] || { echo "xpui.spa is patched but the backup is missing; reinstall Spotify"; return 1; } fi stage="$(mktemp -d)" zip_read "$backup" index.html > "$stage/original.html" || { rm -rf "$stage"; echo "xpui.spa: 读不到 index.html"; return 1; } zp_inject "$digest" < "$stage/original.html" > "$stage/index.html" ZR_HTML="$stage/index.html" ZR_JS="$js" ZR_CSS="$css" zip_rewrite "$backup" "$spa" || { rm -rf "$stage"; return 1; } rm -rf "$stage" echo "PATCHED spa $spa" ;; restore) local restored="" if [[ -f "$folder/index.html" ]]; then if [[ -n "$(zp_marker < "$folder/index.html")" ]]; then html="$(sed -E "s#$ZP_BLOCK_RE##" "$folder/index.html")"; printf '%s' "$html" > "$folder/index.html"; restored=1 fi rm -rf "$folder/spot-lyric" fi if [[ -f $spa && -n "$(zip_read "$spa" index.html | zp_marker)" ]]; then if [[ -f $backup ]]; then cp -p "$backup" "$spa.tmp" && mv -f "$spa.tmp" "$spa" else stage="$(mktemp -d)" zip_read "$spa" index.html | sed -E "s#$ZP_BLOCK_RE##" > "$stage/index.html" ZR_HTML="$stage/index.html" ZR_JS="" ZR_CSS="" zip_rewrite "$spa" "$spa" || { rm -rf "$stage"; return 1; } rm -rf "$stage" fi restored=1 fi rm -f "$backup" [[ -n $restored ]] && echo RESTORED || echo NOT_PATCHED ;; status) if [[ -f "$folder/index.html" ]]; then current="$(zp_marker < "$folder/index.html")" [[ -n $current ]] && echo "dir patched v$current" || echo "dir not-patched"; return 0; fi [[ -f $spa ]] || { echo missing; return 0; } current="$(zip_read "$spa" index.html | zp_marker)" printf '%s%s\n' "$([[ -n $current ]] && echo "spa patched v$current" || echo "spa not-patched")" "$([[ -f $backup ]] && echo " backup")" ;; esac } patcher() { xpui "$1" "$APPS" "${@:2}"; } # patcher args... apps_writable() { [[ -w "$APPS" ]] && { [[ ! -e "$APPS/xpui.spa" ]] || [[ -w "$APPS/xpui.spa" || -O "$APPS" ]]; } && { [[ ! -d "$APPS/xpui" ]] || [[ -w "$APPS/xpui" ]]; } } run_patcher() { if apps_writable; then patcher "$@" else # Re-run this script's patcher as root with the same environment. say "需要管理员权限写入 Spotify 安装目录(sudo)" >&2 if [[ $(id -u) == 0 ]]; then patcher "$@" else sudo env SPOT_LYRIC_PLATFORM="$PLATFORM" bash "$ROOT/patch.sh" __patcher "$APPS" "$@"; fi fi } describe_patch() { case "$1" in *not-patched*) echo "未注入" ;; *"patched v"*) local v="${1#*patched v}"; echo "已注入 v${v%% *}" ;; missing) echo "找不到 xpui.spa" ;; *) echo "$1" ;; esac } # ------------------------------------------------------------ macOS sign --- # Rewriting Contents/Resources invalidates Spotify's signature; Ventura and later # refuse to start an app whose signature is broken. Re-sign ad hoc like SpotX. macos_resign() { [[ $PLATFORM == macos ]] || return 0 command -v codesign >/dev/null 2>&1 || { warn "未找到 codesign,跳过重新签名(如 Spotify 无法启动,请安装 Xcode Command Line Tools:xcode-select --install)"; return 0; } local run=""; [[ -w "$SPOTIFY_DIR" ]] || { run=privileged; need_root "重新签名 Spotify.app"; } $run xattr -cr "$SPOTIFY_DIR" 2>/dev/null || true if $run codesign -f --deep -s - "$SPOTIFY_DIR" >/dev/null 2>&1 && codesign --verify --deep --strict "$SPOTIFY_DIR" >/dev/null 2>&1; then say "已重新签名 Spotify.app(ad-hoc)" else warn "重新签名失败。若 Spotify 打不开,请运行:codesign -f --deep -s - \"$SPOTIFY_DIR\"" fi } macos_permission_hint() { warn "写入被 macOS 拒绝(Operation not permitted)。" warn "请到 系统设置 > 隐私与安全性 > App 管理(或 完全磁盘访问权限)中允许当前终端 App,然后重新运行。" } PATCH_CHANGED=0 do_apply() { # do_apply MODE SERVER REQUEST local tmp result [[ $SPOTIFY_DIR == /snap/* ]] && die "Snap 版 Spotify 是只读文件系统,无法注入。请改用 deb 或 flatpak 版本。" tmp="$(mktemp -d)" build_bundle "$tmp" "$1" "$2" "$3" if ! result="$(run_patcher patch "$tmp/spot-lyric.js" "$tmp/spot-lyric.css" 2>&1)"; then rm -rf "$tmp" [[ $PLATFORM == macos && $result == *"ermitted"* ]] && macos_permission_hint die "注入失败:$result" fi rm -rf "$tmp" case "$result" in *UNCHANGED*) say "插件已是最新(v${VERSION},$(mode_name "$1")),无需修改"; PATCH_CHANGED=0 ;; *PATCHED*) say "已注入歌词插件 v${VERSION}($(mode_name "$1"))→ ${result##*PATCHED * }"; PATCH_CHANGED=1; macos_resign ;; *) [[ $PLATFORM == macos && $result == *"ermitted"* ]] && macos_permission_hint; die "注入失败:$result" ;; esac } # ------------------------------------------------------- legacy proxy --- # Versions before 1.1 ran a local proxy (spot-lyric-proxy, 127.0.0.1:38917). It is # removed so the port is free for the local service. if [[ $PLATFORM == macos ]]; then LEGACY_DIR="$TARGET_HOME/Library/Application Support/SpotLyric" else LEGACY_DIR="$TARGET_HOME/.local/share/spot-lyric-patch" fi PROXY_LABEL=com.spotlyric.proxy REAPPLY_LABEL=com.spotlyric.reapply SERVICE_LABEL=com.spotlyric.local SERVICE_UNIT=spot-lyric-local.service have_user_systemd() { [[ $PLATFORM == linux ]] && as_user systemctl --user show-environment >/dev/null 2>&1; } launch_agent() { # launch_agent