From c33061ddfe4e031012f0fea55edcc0c9b85678fc Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Tue, 7 Apr 2026 09:29:52 +0800 Subject: [PATCH 01/26] Update DataLoaderServiceHooks.x.swift to improve visual ads ad-blocking --- .../DataLoaderServiceHooks.x.swift | 85 +++++++++++-------- 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index 8faad1be..60e18e66 100644 --- a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift +++ b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift @@ -20,9 +20,23 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { // orion:new func shouldBlock(_ url: URL) -> Bool { let elapsed = Date().timeIntervalSince(tweakInitTime) + let path = url.path.lowercased() - // Always block explicit session destroy/token delete - if url.isDeleteToken || url.isSessionInvalidation || url.path.contains("session/purge") || url.path.contains("token/revoke") { + // Always block explicit session destroy/token delete or ad-related requests + if url.isDeleteToken || url.isSessionInvalidation || path.contains("session/purge") || path.contains("token/revoke") || url.isAdRelated { + return true + } + + // Block all DAC (Display Ad Container) ad requests. + // The DAC endpoint delivers the search-page and home-page display ads + // (e.g. Cartier on Search, Ross on Home shown in the screenshots). + // Any /dac/view/v1/ request is ad-related; return empty to suppress. + if path.contains("/dac/view/v1/") { + return true + } + + // Block the Esperanto ad slot service used for in-stream and overlay ads + if path.contains("/esperanto/") && (path.contains("ad") || path.contains("slot")) { return true } @@ -32,6 +46,12 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { || url.isTrialsFacade || url.isPremiumMarketing || url.isPendragonFetchMessageList || url.isPushkaTokens || url.path.contains("signup/public") || url.path.contains("apresolve") || url.path.contains("pses/screenconfig") || url.path.contains("bootstrap/v1/bootstrap") + // Block periodic customize re-fetches (RemoteConfigurationSDK AuthFetcher). + // The AuthFetcher re-fetches v1/customize after minimumFetchIntervalSeconds + // (typically a few hours). If this re-fetch is not intercepted and modified, + // the app re-enables ad feature flags from the server response. + // We block re-fetches here; the cached modified data is served via the 304 path. + || url.path.contains("v1/customize") } return false @@ -43,11 +63,11 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { let shouldReplaceLyrics = BaseLyricsGroup.isActive let isLyricsURL = url.isLyrics - if isLyricsURL { - } + let path = url.path.lowercased() + let isDAC = path.contains("/dac/view/v1/") return (shouldReplaceLyrics && isLyricsURL) - || (shouldPatchPremium && (url.isCustomize || url.isPremiumPlanRow || url.isPremiumBadge || url.isPlanOverview)) + || (shouldPatchPremium && (url.isCustomize || url.isPremiumPlanRow || url.isPremiumBadge || url.isPlanOverview || isDAC)) } // orion:new @@ -84,6 +104,21 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { respondWithCustomData("{}".data(using: .utf8)!, task: task, session: session) } else if url.path.contains("bootstrap/v1/bootstrap") { respondWithCustomData("{}".data(using: .utf8)!, task: task, session: session) + } else if url.isAdRelated { + respondWithCustomData(Data(), task: task, session: session) + } else if url.path.lowercased().contains("/dac/view/v1/") { + // Return empty data for DAC ad requests + respondWithCustomData(Data(), task: task, session: session) + } else if url.path.lowercased().contains("/esperanto/") { + // Return empty data for Esperanto ad slot requests + respondWithCustomData(Data(), task: task, session: session) + } else if url.path.contains("v1/customize") { + // Serve the cached modified customize data for periodic re-fetches + if let cached = SPTDataLoaderServiceHook.cachedCustomizeData { + respondWithCustomData(cached, task: task, session: session) + } else { + respondWithCustomData(Data(), task: task, session: session) + } } orig.URLSession(session, task: task, didCompleteWithError: nil) } @@ -100,7 +135,7 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { auth.hasPrefix("Bearer ") { spotifyAccessToken = String(auth.dropFirst(7)) } - + guard let url = task.currentRequest?.url else { orig.URLSession(session, task: task, didCompleteWithError: error) return @@ -132,16 +167,13 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { return } - do { if url.isLyrics { - let originalLyrics = try? Lyrics(serializedBytes: buffer) // Try to fetch custom lyrics with a timeout let semaphore = DispatchSemaphore(value: 0) var customLyricsData: Data? - var customLyricsError: Error? DispatchQueue.global(qos: .userInitiated).async { do { @@ -150,43 +182,19 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { originalLyrics: originalLyrics ) } catch { - customLyricsError = error } semaphore.signal() } - // Wait up to 5 seconds for custom lyrics (cached LRCLIB responses are instant) + // Wait up to 5 seconds for custom lyrics let timeout = DispatchTime.now() + .milliseconds(5000) let result = semaphore.wait(timeout: timeout) if result == .success, let data = customLyricsData { respondWithCustomData(data, task: task, session: session) - - // Show popup indicating custom lyrics source - DISABLED FOR PRODUCTION - // DispatchQueue.main.async { - // PopUpHelper.showPopUp( - // message: "🎵 Using \(UserDefaults.lyricsSource.description) lyrics", - // buttonText: "OK" - // ) - // } - - // Complete the request orig.URLSession(session, task: task, didCompleteWithError: nil) } else { - if result == .timedOut { - } else { - } respondWithCustomData(buffer, task: task, session: session) - - // Show popup indicating fallback to original - DISABLED FOR PRODUCTION - // DispatchQueue.main.async { - // PopUpHelper.showPopUp( - // message: result == .timedOut ? "⏱️ Using Spotify Original (timeout)" : "🎵 Using Spotify Original", - // buttonText: "OK" - // ) - // } - - // Complete the request orig.URLSession(session, task: task, didCompleteWithError: nil) } return @@ -225,6 +233,15 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { orig.URLSession(session, task: task, didCompleteWithError: nil) return } + + if url.path.lowercased().contains("/dac/view/v1/") { + // For DAC responses, we return an empty valid response to hide ads/upsells + // Most DAC endpoints expect a protobuf or JSON response. + // Returning empty data or a minimal valid object is safer than blocking. + respondWithCustomData(Data(), task: task, session: session) + orig.URLSession(session, task: task, didCompleteWithError: nil) + return + } } catch { orig.URLSession(session, task: task, didCompleteWithError: error) From 85c0df578a52ab4d4e2b37aceaab02ebc7c18e81 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Wed, 15 Apr 2026 22:36:44 +0800 Subject: [PATCH 02/26] Create main.yml --- .github/workflows/main.yml | 122 +++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..f5ea90fa --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,122 @@ +name: Build Rootless Deb and inject .deb into IPA + +on: + workflow_dispatch: + inputs: + ipa_url: + description: "URL to the decrypted IPA file (optional for IPA build)" + default: "" + required: false + type: string + +env: + THEOS: ${{ github.workspace }}/theos + SWIFTPROTOBUF_VERSION: "1.29.0" + +jobs: + build: + runs-on: macos-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2.0' + + - name: Setup Theos + uses: actions/checkout@v4 + with: + repository: theos/theos + path: ${{ github.workspace }}/theos + submodules: recursive + + - name: Install Dependencies + run: | + brew install make dpkg ldid wget + # Ensure we use GNU Make + echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH + - name: Setup SwiftProtobuf (Rootless) + run: | + mkdir -p swiftprotobuf-rootless && cd swiftprotobuf-rootless + wget https://github.com/whoeevee/swift-protobuf/releases/download/${{ env.SWIFTPROTOBUF_VERSION }}/org.swift.protobuf.swiftprotobuf_${{ env.SWIFTPROTOBUF_VERSION }}_iphoneos-arm64.deb + ar -x org.swift.protobuf.swiftprotobuf_${{ env.SWIFTPROTOBUF_VERSION }}_iphoneos-arm64.deb + tar -xf data.tar.lzma + mkdir -p $THEOS/lib/iphone/rootless + cp -r var/jb/Library/Frameworks/SwiftProtobuf.framework $THEOS/lib/iphone/rootless/ + - name: Build Rootless Package + run: | + make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless + # Find the generated deb file + DEB_PATH=$(find packages -name "*.deb" | head -n 1) + echo "DEB_PATH=$DEB_PATH" >> $GITHUB_ENV + echo "DEB_NAME=$(basename $DEB_PATH)" >> $GITHUB_ENV + - name: Upload DEB Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.DEB_NAME }} + path: ${{ env.DEB_PATH }} + if-no-files-found: error + + - name: Build IPA (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + # Download and Validate IPA + wget "${{ inputs.ipa_url }}" --no-verbose -O ${{ github.workspace }}/spotify.ipa + file_type=$(file --mime-type -b ${{ github.workspace }}/spotify.ipa) + if [[ "$file_type" != "application/x-ios-app" && "$file_type" != "application/zip" ]]; then + echo "::error::Validation failed: The downloaded file is not a valid IPA. Detected type: $file_type." + exit 1 + fi + - name: Download SwiftProtobuf for IPA (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + wget https://github.com/whoeevee/swift-protobuf/releases/download/${{ env.SWIFTPROTOBUF_VERSION }}/org.swift.protobuf.swiftprotobuf_${{ env.SWIFTPROTOBUF_VERSION }}_iphoneos-arm.deb + wget https://github.com/whoeevee/swift-protobuf/releases/download/${{ env.SWIFTPROTOBUF_VERSION }}/org.swift.protobuf.swiftprotobuf_${{ env.SWIFTPROTOBUF_VERSION }}_iphoneos-arm64.deb + echo "SWIFTPROTOBUF_ROOTFUL=org.swift.protobuf.swiftprotobuf_${{ env.SWIFTPROTOBUF_VERSION }}_iphoneos-arm.deb" >> $GITHUB_ENV + echo "SWIFTPROTOBUF_ROOTLESS=org.swift.protobuf.swiftprotobuf_${{ env.SWIFTPROTOBUF_VERSION }}_iphoneos-arm64.deb" >> $GITHUB_ENV + - name: Download OpenSpotifySafariExtension (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + git clone https://github.com/BillyCurtis/OpenSpotifySafariExtension + echo "OPENSPOTIFYSAFARIEXTENSION=OpenSpotifySafariExtension/OpenSpotifySafariExtension.appex" >> $GITHUB_ENV + - name: Setup insert-dylib (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + git clone https://github.com/Tyilo/insert_dylib.git + xcrun clang -x c -arch arm64 ./insert_dylib/insert_dylib/main.c -I/usr/include/ -o insert-dylib + sudo mv insert-dylib /usr/local/bin/ + sudo chmod +x /usr/local/bin/insert-dylib + - name: Setup ivinject (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + git clone https://github.com/whoeevee/ivinject.git + cp -r ./ivinject/KnownFrameworks ~/.ivinject + wget https://github.com/whoeevee/ivinject/releases/download/first/ivinject-arm64 + sudo mv ivinject-arm64 /usr/local/bin/ + sudo chmod +x /usr/local/bin/ivinject-arm64 + - name: Remove Watch App from IPA (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + unzip -q spotify.ipa -d spotify_temp + rm -rf spotify_temp/Payload/Spotify.app/Watch + rm -rf spotify_temp/Payload/Spotify.app/PlugIns/*.appex + cd spotify_temp + zip -qr ../spotify_clean.ipa Payload + cd .. + rm -rf spotify_temp + mv spotify_clean.ipa spotify.ipa + - name: Create EeveeSpotify IPA Package (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + run: | + ivinject-arm64 spotify.ipa EeveeSpotify.ipa \ + -i ${{ env.DEB_PATH }} "$OPENSPOTIFYSAFARIEXTENSION" \ + -s - -d --level Optimal + - name: Upload IPA Artifact (if IPA URL provided) + if: ${{ inputs.ipa_url != '' }} + uses: actions/upload-artifact@v4 + with: + name: EeveeSpotify.ipa + path: ${{ github.workspace }}/EeveeSpotify.ipa + if-no-files-found: warn From c52a88694084982aa1c258e71859660efaa5ab6e Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Sat, 18 Apr 2026 21:50:51 +0800 Subject: [PATCH 03/26] Update SessionProtection.x.swift --- .../EeveeSpotify/SessionProtection.x.swift | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Sources/EeveeSpotify/SessionProtection.x.swift b/Sources/EeveeSpotify/SessionProtection.x.swift index 0de63c66..1632363f 100644 --- a/Sources/EeveeSpotify/SessionProtection.x.swift +++ b/Sources/EeveeSpotify/SessionProtection.x.swift @@ -8,7 +8,13 @@ import Foundation // Additionally blocks network endpoints that trigger session invalidation. // Extends OAuth token expiry to prevent internal reauth triggers. -struct SessionLogoutHookGroup: HookGroup { } +// Split into smaller groups so missing selectors/classes don't crash on activation. +// Spotify occasionally renames/removes private session-related selectors between minor versions. +// By gating each group behind runtime checks, we keep compatibility across 9.1.x. +struct SessionLogoutAuthHookGroup: HookGroup { } +struct SessionLogoutConnectivityHookGroup: HookGroup { } +struct SessionLogoutAblyHookGroup: HookGroup { } +struct SessionLogoutNetworkHookGroup: HookGroup { } // Ably action name mapping for readable logs private let ablyActionNames: [Int: String] = [ @@ -21,7 +27,7 @@ private let ablyActionNames: [Int: String] = [ // MARK: - SPTAuthSessionImplementation — Core Session Hooks class SPTAuthSessionHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutAuthHookGroup static let targetName = "SPTAuthSessionImplementation" // orion:new @@ -91,7 +97,7 @@ class SPTAuthSessionHook: ClassHook { // MARK: - SessionServiceImpl (Connectivity_SessionImpl module) class SessionServiceImplHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutConnectivityHookGroup static let targetName = "_TtC24Connectivity_SessionImpl18SessionServiceImpl" func automatedLogoutThenLogin() { @@ -126,7 +132,7 @@ class SessionServiceImplHook: ClassHook { // MARK: - SPTAuthLegacyLoginControllerImplementation class LegacyLoginControllerHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutAuthHookGroup static let targetName = "SPTAuthLegacyLoginControllerImplementation" func sessionDidLogout(_ session: AnyObject, withReason reason: AnyObject) { @@ -172,7 +178,7 @@ class LegacyLoginControllerHook: ClassHook { // the internal timer from marking the token as expired. class OauthAccessTokenBridgeHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutConnectivityHookGroup static let targetName = "_TtC24Connectivity_SessionImplP33_831B98CC28223E431E21CD27ADD20AF222OauthAccessTokenBridge" // Hook the GETTER @@ -245,7 +251,7 @@ private func extractAblyAction(_ text: String) -> Int? { } class ARTWebSocketTransportHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutAblyHookGroup static let targetName = "ARTWebSocketTransport" func webSocket(_ ws: AnyObject, didReceiveMessage message: AnyObject) { @@ -286,7 +292,7 @@ class ARTWebSocketTransportHook: ClassHook { // MARK: - Ably SRWebSocket Frame Hook class ARTSRWebSocketHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutAblyHookGroup static let targetName = "ARTSRWebSocket" func _handleFrameWithData(_ data: NSData, opCode code: Int) { @@ -321,7 +327,7 @@ class ARTSRWebSocketHook: ClassHook { // MARK: - Global URLSessionTask hook to catch auth traffic bypassing SPTDataLoaderService class URLSessionTaskResumeHook: ClassHook { - typealias Group = SessionLogoutHookGroup + typealias Group = SessionLogoutNetworkHookGroup static let targetName = "NSURLSessionTask" func resume() { From 3a6d987218bf014dc65bf0e6d44fdd3dad597b94 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Sat, 18 Apr 2026 21:51:16 +0800 Subject: [PATCH 04/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 77 +++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index 6ff5db48..970ea115 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -1,6 +1,7 @@ import Orion import EeveeSpotifyC import UIKit +import Foundation func writeDebugLog(_ message: String) { // Log to system console @@ -79,6 +80,79 @@ func activatePremiumPatchingGroup() { } } +// MARK: - Session protection activation +// Guard each hook group behind runtime checks so minor Spotify updates +// (e.g., 9.1.34 -> 9.1.36) don't crash the app at launch due to +// missing private selectors. +func activateSessionLogoutProtection() { + func log(_ msg: String) { + NSLog("[EeveeSpotify][SessionProtect] %@", msg) + } + + // Auth hooks + if let cls = NSClassFromString("SPTAuthSessionImplementation") { + let required: [Selector] = [ + Selector(("logout")), + Selector(("logoutWithReason:")), + Selector(("callSessionDidLogoutOnDelegateWithReason:")), + Selector(("logWillLogoutEventWithLogoutReason:")), + Selector(("destroy")), + ] + let ok = required.allSatisfy { (cls as AnyObject).instancesRespond(to: $0) } + if ok { + SessionLogoutAuthHookGroup().activate() + log("Activated auth hooks") + } else { + log("Skipped auth hooks (missing selector)") + } + } else { + log("Skipped auth hooks (missing class SPTAuthSessionImplementation)") + } + + // Connectivity hooks + if let cls = NSClassFromString("_TtC24Connectivity_SessionImpl18SessionServiceImpl") { + let required: [Selector] = [ + Selector(("automatedLogoutThenLogin")), + Selector(("userInitiatedLogout")), + Selector(("sessionDidLogout:withReason:")), + ] + let ok = required.allSatisfy { (cls as AnyObject).instancesRespond(to: $0) } + if ok { + SessionLogoutConnectivityHookGroup().activate() + log("Activated connectivity hooks") + } else { + log("Skipped connectivity hooks (missing selector)") + } + } else { + log("Skipped connectivity hooks (missing class SessionServiceImpl)") + } + + // Ably hooks + if let cls = NSClassFromString("ARTWebSocketTransport") { + let required: [Selector] = [ + Selector(("webSocket:didReceiveMessage:")), + Selector(("webSocket:didFailWithError:")), + ] + let ok = required.allSatisfy { (cls as AnyObject).instancesRespond(to: $0) } + if ok { + SessionLogoutAblyHookGroup().activate() + log("Activated Ably hooks") + } else { + log("Skipped Ably hooks (missing selector)") + } + } else { + log("Skipped Ably hooks (missing class ARTWebSocketTransport)") + } + + // Network hooks + if let cls = NSClassFromString("NSURLSessionTask"), (cls as AnyObject).instancesRespond(to: #selector(URLSessionTask.resume)) { + SessionLogoutNetworkHookGroup().activate() + log("Activated URLSessionTask hooks") + } else { + log("Skipped URLSessionTask hooks (missing selector)") + } +} + struct EeveeSpotify: Tweak { static let version = "6.6.2" static let buildNumber = "1" @@ -103,7 +177,8 @@ struct EeveeSpotify: Tweak { init() { // Activate session logout protection first (all versions) - SessionLogoutHookGroup().activate() + // (Guarded to avoid launch crashes on minor Spotify updates) + activateSessionLogoutProtection() let spotifyVersion = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String let spotifyBuild = Bundle.main.infoDictionary!["CFBundleVersion"] as? String ?? "?" From bd1bceac4d2f5e9f8e9876f6680064fa11cdd5fd Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Sat, 18 Apr 2026 22:00:42 +0800 Subject: [PATCH 05/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index 970ea115..510b4c30 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -89,6 +89,11 @@ func activateSessionLogoutProtection() { NSLog("[EeveeSpotify][SessionProtect] %@", msg) } + @inline(__always) + func classHasInstanceMethod(_ cls: AnyClass, _ sel: Selector) -> Bool { + return class_getInstanceMethod(cls, sel) != nil + } + // Auth hooks if let cls = NSClassFromString("SPTAuthSessionImplementation") { let required: [Selector] = [ @@ -98,7 +103,7 @@ func activateSessionLogoutProtection() { Selector(("logWillLogoutEventWithLogoutReason:")), Selector(("destroy")), ] - let ok = required.allSatisfy { (cls as AnyObject).instancesRespond(to: $0) } + let ok = required.allSatisfy { classHasInstanceMethod(cls, $0) } if ok { SessionLogoutAuthHookGroup().activate() log("Activated auth hooks") @@ -116,7 +121,7 @@ func activateSessionLogoutProtection() { Selector(("userInitiatedLogout")), Selector(("sessionDidLogout:withReason:")), ] - let ok = required.allSatisfy { (cls as AnyObject).instancesRespond(to: $0) } + let ok = required.allSatisfy { classHasInstanceMethod(cls, $0) } if ok { SessionLogoutConnectivityHookGroup().activate() log("Activated connectivity hooks") @@ -133,7 +138,7 @@ func activateSessionLogoutProtection() { Selector(("webSocket:didReceiveMessage:")), Selector(("webSocket:didFailWithError:")), ] - let ok = required.allSatisfy { (cls as AnyObject).instancesRespond(to: $0) } + let ok = required.allSatisfy { classHasInstanceMethod(cls, $0) } if ok { SessionLogoutAblyHookGroup().activate() log("Activated Ably hooks") @@ -145,7 +150,7 @@ func activateSessionLogoutProtection() { } // Network hooks - if let cls = NSClassFromString("NSURLSessionTask"), (cls as AnyObject).instancesRespond(to: #selector(URLSessionTask.resume)) { + if let cls = NSClassFromString("NSURLSessionTask"), classHasInstanceMethod(cls, #selector(URLSessionTask.resume)) { SessionLogoutNetworkHookGroup().activate() log("Activated URLSessionTask hooks") } else { From b23d9ef2a33c4c5e206ca997369bffad81df7875 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Sat, 18 Apr 2026 22:21:30 +0800 Subject: [PATCH 06/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index 510b4c30..a1fe59f4 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -84,7 +84,7 @@ func activatePremiumPatchingGroup() { // Guard each hook group behind runtime checks so minor Spotify updates // (e.g., 9.1.34 -> 9.1.36) don't crash the app at launch due to // missing private selectors. -func activateSessionLogoutProtection() { +func activateSessionLogoutProtection(minimal: Bool) { func log(_ msg: String) { NSLog("[EeveeSpotify][SessionProtect] %@", msg) } @@ -94,6 +94,18 @@ func activateSessionLogoutProtection() { return class_getInstanceMethod(cls, sel) != nil } + if minimal { + // Only the URLSessionTask hook (used for diagnostics + cancelling revoke endpoints) + // tends to be stable across minor versions. + if let cls = NSClassFromString("NSURLSessionTask"), classHasInstanceMethod(cls, #selector(URLSessionTask.resume)) { + SessionLogoutNetworkHookGroup().activate() + log("Activated URLSessionTask hooks (minimal)") + } else { + log("Skipped URLSessionTask hooks (missing selector)") + } + return + } + // Auth hooks if let cls = NSClassFromString("SPTAuthSessionImplementation") { let required: [Selector] = [ @@ -181,9 +193,15 @@ struct EeveeSpotify: Tweak { } init() { - // Activate session logout protection first (all versions) - // (Guarded to avoid launch crashes on minor Spotify updates) - activateSessionLogoutProtection() + // Activate session logout protection first. + // NOTE: On some Spotify 9.1.x builds, Orion can still crash even if a selector exists + // (e.g., method type encoding changes). Be conservative for 9.1.x. + if EeveeSpotify.hookTarget == .v91 { + // Minimal protection only (safest hook) + activateSessionLogoutProtection(minimal: true) + } else { + activateSessionLogoutProtection(minimal: false) + } let spotifyVersion = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String let spotifyBuild = Bundle.main.infoDictionary!["CFBundleVersion"] as? String ?? "?" From 288073fc9e5ad4fba14fd0df0dfc09bf35f969d6 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Sat, 18 Apr 2026 22:37:11 +0800 Subject: [PATCH 07/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 51 ++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index a1fe59f4..b1b83e59 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -2,6 +2,7 @@ import Orion import EeveeSpotifyC import UIKit import Foundation +import ObjectiveC.runtime func writeDebugLog(_ message: String) { // Log to system console @@ -241,23 +242,59 @@ struct EeveeSpotify: Tweak { // For 9.1.x, activate premium patching and lyrics if EeveeSpotify.hookTarget == .v91 { - // Premium patching + // Premium patching (guarded) if UserDefaults.patchType.isPatching { - BasePremiumPatchingGroup().activate() + if let hub = NSClassFromString("HUBViewModelBuilderImplementation"), + class_getInstanceMethod(hub, Selector(("addJSONDictionary:"))) != nil { + BasePremiumPatchingGroup().activate() + } else { + writeDebugLog("[INIT] Skipped BasePremiumPatchingGroup (missing HUBViewModelBuilderImplementation/addJSONDictionary:)") + } } let lyricsEnabled = UserDefaults.lyricsSource.isReplacingLyrics + // Lyrics hooks (guarded) if lyricsEnabled { - BaseLyricsGroup().activate() - V91LyricsGroup().activate() + let fullscreenOK: Bool = { + // For 9.1.x, targetName resolves to Lyrics_FullscreenElementPageImpl.FullscreenElementViewController + if let cls = NSClassFromString("Lyrics_FullscreenElementPageImpl.FullscreenElementViewController") { + return class_getInstanceMethod(cls, #selector(UIViewController.viewDidLoad)) != nil + } + return false + }() - } else { + let npvOK: Bool = { + if let cls = NSClassFromString("NowPlaying_ScrollImpl.NPVScrollViewController") { + return class_getInstanceMethod(cls, #selector(UIViewController.viewWillAppear(_:))) != nil + && class_getInstanceMethod(cls, #selector(UIViewController.viewWillDisappear(_:))) != nil + } + return false + }() + + if fullscreenOK { + BaseLyricsGroup().activate() + } else { + writeDebugLog("[INIT] Skipped BaseLyricsGroup (fullscreen VC missing)") + } + + if npvOK { + V91LyricsGroup().activate() + } else { + writeDebugLog("[INIT] Skipped V91LyricsGroup (NPVScrollViewController missing)") + } } - // Settings integration - UniversalSettingsIntegrationGroup().activate() + // Settings integration (guarded) + if let cls = NSClassFromString("ProfileSettingsSection"), + class_getInstanceMethod(cls, Selector(("numberOfRows"))) != nil, + class_getInstanceMethod(cls, Selector(("didSelectRow:"))) != nil, + class_getInstanceMethod(cls, Selector(("cellForRow:"))) != nil { + UniversalSettingsIntegrationGroup().activate() + } else { + writeDebugLog("[INIT] Skipped UniversalSettingsIntegrationGroup (ProfileSettingsSection API mismatch)") + } // Also activate the banner for 9.1.x to ensure visibility if menu is missing // V91SettingsIntegrationGroup().activate() From 34c8eddb8c4d014166a2c6f5087278ba60b5b3fd Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Sat, 18 Apr 2026 22:51:46 +0800 Subject: [PATCH 08/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index b1b83e59..cb039bff 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -171,6 +171,28 @@ func activateSessionLogoutProtection(minimal: Bool) { } } +// MARK: - Bootstrap breadcrumbs +@inline(__always) +func eeveeBreadcrumb(_ label: String) { + let path = NSTemporaryDirectory() + "eeveespotify_boot.txt" + let ts = Date().description + let line = "[\(ts)] \(label)\n" + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: path), let h = FileHandle(forWritingAtPath: path) { + h.seekToEndOfFile(); h.write(data); try? h.close() + } else { + try? data.write(to: URL(fileURLWithPath: path)) + } + } +} + +@inline(__always) +func eeveeEnvFlag(_ name: String) -> Bool { + guard let v = getenv(name) else { return false } + let s = String(cString: v).lowercased() + return s == "1" || s == "true" || s == "yes" || s == "y" +} + struct EeveeSpotify: Tweak { static let version = "6.6.2" static let buildNumber = "1" @@ -194,6 +216,15 @@ struct EeveeSpotify: Tweak { } init() { + eeveeBreadcrumb("Tweak init() entered") + + // Global kill-switch for debugging “instant crash / no logs”. + // If setting this makes Spotify launch, the crash is definitely in one of our hook activations. + if eeveeEnvFlag("EEVEE_DISABLE_ALL") { + eeveeBreadcrumb("EEVEE_DISABLE_ALL=1 -> returning without hooks") + return + } + // Activate session logout protection first. // NOTE: On some Spotify 9.1.x builds, Orion can still crash even if a selector exists // (e.g., method type encoding changes). Be conservative for 9.1.x. From aa1c83e155748b8797282bcdf845a1cd27811ee0 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 00:59:03 +0800 Subject: [PATCH 09/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index cb039bff..c49f49e0 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -322,9 +322,22 @@ struct EeveeSpotify: Tweak { class_getInstanceMethod(cls, Selector(("numberOfRows"))) != nil, class_getInstanceMethod(cls, Selector(("didSelectRow:"))) != nil, class_getInstanceMethod(cls, Selector(("cellForRow:"))) != nil { - UniversalSettingsIntegrationGroup().activate() + + UniversalSettingsIntegrationProfileGroup().activate() + + if NSClassFromString("SettingsViewController") != nil { + UniversalSettingsIntegrationSettingsVCGroup().activate() + } + // RootSettingsViewController was removed in some 9.1.x builds (9.1.36). + // Only activate if the class exists. + if NSClassFromString("RootSettingsViewController") != nil { + UniversalSettingsIntegrationRootSettingsVCGroup().activate() + } + // UINavigationController exists; this hook is generic and safe. + UniversalSettingsIntegrationNavGroup().activate() + } else { - writeDebugLog("[INIT] Skipped UniversalSettingsIntegrationGroup (ProfileSettingsSection API mismatch)") + writeDebugLog("[INIT] Skipped settings integration (ProfileSettingsSection API mismatch)") } // Also activate the banner for 9.1.x to ensure visibility if menu is missing // V91SettingsIntegrationGroup().activate() @@ -396,7 +409,12 @@ struct EeveeSpotify: Tweak { } // Always activate settings integration (except for 9.1.x which exits early above) - UniversalSettingsIntegrationGroup().activate() + UniversalSettingsIntegrationProfileGroup().activate() + UniversalSettingsIntegrationSettingsVCGroup().activate() + if NSClassFromString("RootSettingsViewController") != nil { + UniversalSettingsIntegrationRootSettingsVCGroup().activate() + } + UniversalSettingsIntegrationNavGroup().activate() SettingsIntegrationGroup().activate() } } From 09d358570779ac8244dfbed3664fafbcb8b35bce Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 00:59:40 +0800 Subject: [PATCH 10/26] Update EeveeSettingsUniversal.x.swift --- .../Settings/EeveeSettingsUniversal.x.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/EeveeSpotify/Settings/EeveeSettingsUniversal.x.swift b/Sources/EeveeSpotify/Settings/EeveeSettingsUniversal.x.swift index 51a690d5..18319a0a 100644 --- a/Sources/EeveeSpotify/Settings/EeveeSettingsUniversal.x.swift +++ b/Sources/EeveeSpotify/Settings/EeveeSettingsUniversal.x.swift @@ -2,12 +2,17 @@ import Orion import SwiftUI import UIKit -// Universal settings integration that works across all Spotify versions -struct UniversalSettingsIntegrationGroup: HookGroup { } +// Universal settings integration. +// Split into multiple HookGroups so missing classes in newer Spotify builds +// (e.g., RootSettingsViewController removed in 9.1.36) don't crash when activating. +struct UniversalSettingsIntegrationProfileGroup: HookGroup { } +struct UniversalSettingsIntegrationSettingsVCGroup: HookGroup { } +struct UniversalSettingsIntegrationRootSettingsVCGroup: HookGroup { } +struct UniversalSettingsIntegrationNavGroup: HookGroup { } // MARK: - Primary: ProfileSettingsSection hook for settings menu row class UniversalProfileSettingsSectionHook: ClassHook { - typealias Group = UniversalSettingsIntegrationGroup + typealias Group = UniversalSettingsIntegrationProfileGroup static let targetName = "ProfileSettingsSection" func numberOfRows() -> Int { @@ -169,7 +174,7 @@ func injectEeveeButton(into target: UIViewController) { // MARK: - Fallback: Hook SettingsViewController directly (New UI) class SettingsViewControllerHook: ClassHook { - typealias Group = UniversalSettingsIntegrationGroup + typealias Group = UniversalSettingsIntegrationSettingsVCGroup static let targetName = "SettingsViewController" func viewDidLoad() { @@ -185,7 +190,7 @@ class SettingsViewControllerHook: ClassHook { // MARK: - Fallback: Hook RootSettingsViewController directly class RootSettingsViewControllerHook: ClassHook { - typealias Group = UniversalSettingsIntegrationGroup + typealias Group = UniversalSettingsIntegrationRootSettingsVCGroup static let targetName = "RootSettingsViewController" func viewDidLoad() { @@ -201,7 +206,7 @@ class RootSettingsViewControllerHook: ClassHook { // MARK: - Generic Fallback: Hook UINavigationController to catch Settings by title/class name class SettingsNavigationStackHook: ClassHook { - typealias Group = UniversalSettingsIntegrationGroup + typealias Group = UniversalSettingsIntegrationNavGroup func pushViewController(_ viewController: UIViewController, animated: Bool) { orig.pushViewController(viewController, animated: animated) From 26fa93371af0774a441b7c4b6a4c3a99c745e476 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 01:33:23 +0800 Subject: [PATCH 11/26] Update main.yml --- .github/workflows/main.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5ea90fa..13e35198 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,6 +3,11 @@ name: Build Rootless Deb and inject .deb into IPA on: workflow_dispatch: inputs: + branch: + description: "Branch to build from" + default: "main" + required: true + type: string ipa_url: description: "URL to the decrypted IPA file (optional for IPA build)" default: "" @@ -19,6 +24,9 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 From 4f3ef30e760565b735aa99ec6cbb7f46aaf04a4a Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 01:34:50 +0800 Subject: [PATCH 12/26] Update DynamicPremium+ModifyingFunctions.swift --- .../Premium/DynamicPremium+ModifyingFunctions.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift index 6951ca6d..ad485ad0 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift @@ -4,12 +4,14 @@ import UIKit func modifyRemoteConfiguration(_ configuration: inout UcsResponse) { modifyAttributes(&configuration.attributes.accountAttributes) + // IMPORTANT: + // Always apply our assignedValues patching. Some accounts receive different remote configs, + // and using a static bundled resolve config alone can regress back to Free tier. + modifyAssignedValues(&configuration.assignedValues) + if UserDefaults.overwriteConfiguration { configuration.resolve.configuration = try! BundleHelper.shared.resolveConfiguration() } - else { - modifyAssignedValues(&configuration.assignedValues) - } } private let propertyReplacements = [ From fffaee430ff59ffa6de0ecc7f43c4515ccfc5b8b Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 01:35:22 +0800 Subject: [PATCH 13/26] Update UserDefaults+Extension.swift --- .../Shared/Models/Extensions/UserDefaults+Extension.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift b/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift index 29df594c..6449091b 100644 --- a/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift +++ b/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift @@ -36,7 +36,9 @@ extension UserDefaults { return EeveePatchType(rawValue: rawValue) ?? .requests } - return .notSet + // If the key is missing (fresh install / "reset data"), default to patching. + // This avoids users silently falling back to Free tier. + return .requests } set (patchType) { container.set(patchType.rawValue, forKey: patchTypeKey) From 51740c9338555303911ac0f46255d6b3f993596f Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 02:02:08 +0800 Subject: [PATCH 14/26] Update DynamicPremium+ModifyBootstrap.x.swift --- .../Premium/DynamicPremium+ModifyBootstrap.x.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift index dd1ed548..ad291dfd 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift @@ -9,6 +9,9 @@ private func showHavePremiumPopUp() { } class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDelegate { + // This hook is the *core* of premium patching (intercepts bootstrap and mutates UCS). + // It MUST be active on 9.1.x too, otherwise users will remain Free / revert after reset. + typealias Group = BasePremiumPatchingGroup static var targetName: String { switch EeveeSpotify.hookTarget { case .lastAvailableiOS14: return "SPTCoreURLSessionDataDelegate" From 5685c036366b563d75e86ec2fbeaa0ab152bd75d Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 18:19:42 +0800 Subject: [PATCH 15/26] Update LikedSongsEnabler.x.swift --- Sources/EeveeSpotify/Premium/LikedSongsEnabler.x.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/Premium/LikedSongsEnabler.x.swift b/Sources/EeveeSpotify/Premium/LikedSongsEnabler.x.swift index 0ac07f0f..b9bc985d 100644 --- a/Sources/EeveeSpotify/Premium/LikedSongsEnabler.x.swift +++ b/Sources/EeveeSpotify/Premium/LikedSongsEnabler.x.swift @@ -6,7 +6,7 @@ private let likedTracksRow: [String: Any] = [ ] class HUBViewModelBuilderImplementationHook: ClassHook { - typealias Group = BasePremiumPatchingGroup + typealias Group = PremiumUIHooksGroup static let targetName: String = "HUBViewModelBuilderImplementation" func addJSONDictionary(_ dictionary: NSDictionary?) { From 116848754b6e32075fa2d8fa9a99c0656315817a Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 18:20:13 +0800 Subject: [PATCH 16/26] Update SiriNoPlayAsRadio.x.swift --- Sources/EeveeSpotify/Premium/SiriNoPlayAsRadio.x.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/Premium/SiriNoPlayAsRadio.x.swift b/Sources/EeveeSpotify/Premium/SiriNoPlayAsRadio.x.swift index 5b34f46c..29e5045d 100644 --- a/Sources/EeveeSpotify/Premium/SiriNoPlayAsRadio.x.swift +++ b/Sources/EeveeSpotify/Premium/SiriNoPlayAsRadio.x.swift @@ -2,7 +2,7 @@ import Orion import Intents class INMediaItemHook: ClassHook { - typealias Group = BasePremiumPatchingGroup + typealias Group = PremiumUIHooksGroup func identifier() -> String { var identifier = orig.identifier() From 322c5fde6d600561122517899d37e18d79d8fdac Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 18:21:14 +0800 Subject: [PATCH 17/26] Update DataLoaderServiceHooks.x.swift --- Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index 60e18e66..dba79174 100644 --- a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift +++ b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift @@ -59,7 +59,7 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { // orion:new func shouldModify(_ url: URL) -> Bool { - let shouldPatchPremium = BasePremiumPatchingGroup.isActive + let shouldPatchPremium = BasePremiumPatchingGroup.isActive || PremiumBootstrapGroup.isActive let shouldReplaceLyrics = BaseLyricsGroup.isActive let isLyricsURL = url.isLyrics From 80f888fc2495bd798dd6886bddc4c2099e3d5703 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 18:21:51 +0800 Subject: [PATCH 18/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index c49f49e0..220618dd 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -45,6 +45,11 @@ func exitApplication() { } } +// Premium hooks are split so core network/bootstrap patching can stay enabled +// even if certain UI hooks break on a specific Spotify build. +struct PremiumBootstrapGroup: HookGroup { } // Intercept bootstrap + mutate UCS +struct PremiumUIHooksGroup: HookGroup { } // UI JSON injections, Siri tweaks, etc. + struct BasePremiumPatchingGroup: HookGroup { } struct IOS14PremiumPatchingGroup: HookGroup { } @@ -273,13 +278,18 @@ struct EeveeSpotify: Tweak { // For 9.1.x, activate premium patching and lyrics if EeveeSpotify.hookTarget == .v91 { - // Premium patching (guarded) + // Premium patching (9.1.x) + // Always activate the *bootstrap interceptor*; it is required for premium patching. if UserDefaults.patchType.isPatching { + PremiumBootstrapGroup().activate() + writeDebugLog("[INIT] Activated PremiumBootstrapGroup") + + // Optional UI hooks (safe-gated) if let hub = NSClassFromString("HUBViewModelBuilderImplementation"), class_getInstanceMethod(hub, Selector(("addJSONDictionary:"))) != nil { - BasePremiumPatchingGroup().activate() + PremiumUIHooksGroup().activate() } else { - writeDebugLog("[INIT] Skipped BasePremiumPatchingGroup (missing HUBViewModelBuilderImplementation/addJSONDictionary:)") + writeDebugLog("[INIT] Skipped PremiumUIHooksGroup (missing HUBViewModelBuilderImplementation/addJSONDictionary:)") } } From bfd32aa1273b7abbf474d607760e29208a17677e Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 18:22:23 +0800 Subject: [PATCH 19/26] Update DynamicPremium+ModifyBootstrap.x.swift --- .../Premium/DynamicPremium+ModifyBootstrap.x.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift index ad291dfd..cb58acf7 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift @@ -10,8 +10,7 @@ private func showHavePremiumPopUp() { class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDelegate { // This hook is the *core* of premium patching (intercepts bootstrap and mutates UCS). - // It MUST be active on 9.1.x too, otherwise users will remain Free / revert after reset. - typealias Group = BasePremiumPatchingGroup + typealias Group = PremiumBootstrapGroup static var targetName: String { switch EeveeSpotify.hookTarget { case .lastAvailableiOS14: return "SPTCoreURLSessionDataDelegate" @@ -85,6 +84,7 @@ class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDe } if UserDefaults.patchType == .requests { + writeDebugLog("[BOOTSTRAP] Patching bootstrap UCS response") modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) orig.URLSession( @@ -94,6 +94,7 @@ class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDe ) } else { + writeDebugLog("[BOOTSTRAP] Passing through unmodified bootstrap (patchType=\(UserDefaults.patchType))") orig.URLSession(session, dataTask: task, didReceiveData: buffer) } From 7c4d266eb2f27846b1409598a239ed7d8b6907e6 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 19:00:46 +0800 Subject: [PATCH 20/26] Update DataLoaderServiceHooks.x.swift --- .../EeveeSpotify/DataLoaderServiceHooks.x.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index dba79174..517ae532 100644 --- a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift +++ b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift @@ -9,6 +9,8 @@ func DataLoaderServiceHooks_startCapturing() { } class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { + // Intercepts various responses (customize/plan/lyrics) and now also bootstrap for 9.1.x stability. + typealias Group = PremiumBootstrapGroup static let targetName = "SPTDataLoaderService" // orion:new @@ -67,7 +69,7 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { let isDAC = path.contains("/dac/view/v1/") return (shouldReplaceLyrics && isLyricsURL) - || (shouldPatchPremium && (url.isCustomize || url.isPremiumPlanRow || url.isPremiumBadge || url.isPlanOverview || isDAC)) + || (shouldPatchPremium && (url.isBootstrap || url.isCustomize || url.isPremiumPlanRow || url.isPremiumBadge || url.isPlanOverview || isDAC)) } // orion:new @@ -218,6 +220,19 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { return } + if url.isBootstrap { + // Patch bootstrap on the SPTDataLoaderService path too. + // Some builds / sessions do not hit SpotifySessionDelegateBootstrapHook reliably. + var bootstrapMessage = try BootstrapMessage(serializedBytes: buffer) + writeDebugLog("[BOOTSTRAP] (DL) Patching bootstrap UCS response") + if UserDefaults.patchType == .requests { + modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) + } + respondWithCustomData(try bootstrapMessage.serializedBytes(), task: task, session: session) + orig.URLSession(session, task: task, didCompleteWithError: nil) + return + } + if url.isCustomize { var customizeMessage = try CustomizeMessage(serializedBytes: buffer) modifyRemoteConfiguration(&customizeMessage.response) From 0c87d4e678560b03778bbd0cedf0db39d6daf217 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 20:02:04 +0800 Subject: [PATCH 21/26] Update UserDefaults+Extension.swift --- .../Shared/Models/Extensions/UserDefaults+Extension.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift b/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift index 6449091b..800fa68d 100644 --- a/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift +++ b/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift @@ -11,6 +11,7 @@ extension UserDefaults { private static let lyricsColorsKey = "lyricsColors" private static let lyricsOptionsKey = "lyricsOptions" private static let hasShownCommonIssuesTipKey = "hasShownCommonIssuesTip" + private static let hasPatchedBootstrapKey = "eeveeHasPatchedBootstrap" static var musixmatchToken: String { get { @@ -63,6 +64,11 @@ extension UserDefaults { } } + static var hasPatchedBootstrap: Bool { + get { container.bool(forKey: hasPatchedBootstrapKey) } + set { container.set(newValue, forKey: hasPatchedBootstrapKey) } + } + static var hasShownCommonIssuesTip: Bool { get { container.bool(forKey: hasShownCommonIssuesTipKey) From 41f92bc89307d377a8fc08880f9114d09faa3ae3 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 20:02:36 +0800 Subject: [PATCH 22/26] Update DynamicPremium+ModifyBootstrap.x.swift --- .../EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift index cb58acf7..84e3071a 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift @@ -85,6 +85,7 @@ class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDe if UserDefaults.patchType == .requests { writeDebugLog("[BOOTSTRAP] Patching bootstrap UCS response") + UserDefaults.hasPatchedBootstrap = true modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) orig.URLSession( From 957cd8e58bdec3f4ef798c36fffdd8f6213e62db Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 20:03:03 +0800 Subject: [PATCH 23/26] Update DataLoaderServiceHooks.x.swift --- Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index 517ae532..b392e9d3 100644 --- a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift +++ b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift @@ -225,6 +225,7 @@ class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { // Some builds / sessions do not hit SpotifySessionDelegateBootstrapHook reliably. var bootstrapMessage = try BootstrapMessage(serializedBytes: buffer) writeDebugLog("[BOOTSTRAP] (DL) Patching bootstrap UCS response") + UserDefaults.hasPatchedBootstrap = true if UserDefaults.patchType == .requests { modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) } From 14fcda21aad9b52c8f20a861d0ea49d5268866cc Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 20:03:28 +0800 Subject: [PATCH 24/26] Update SessionProtection.x.swift --- Sources/EeveeSpotify/SessionProtection.x.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/EeveeSpotify/SessionProtection.x.swift b/Sources/EeveeSpotify/SessionProtection.x.swift index 1632363f..2fb5af82 100644 --- a/Sources/EeveeSpotify/SessionProtection.x.swift +++ b/Sources/EeveeSpotify/SessionProtection.x.swift @@ -339,6 +339,15 @@ class URLSessionTaskResumeHook: ClassHook { let elapsedInt = Int(elapsed) let path = url.path + // If we've already patched bootstrap once in this session, block any subsequent + // bootstrap calls. Some 9.1.x builds perform a second bootstrap very early during + // an internal session re-init, and that second response can overwrite our premium state. + if path.contains("bootstrap/v1/bootstrap"), UserDefaults.hasPatchedBootstrap { + writeDebugLog("[NET] Cancelled bootstrap re-fetch at \(elapsedInt)s") + task.cancel() + return + } + // Log auth-related requests for diagnostics let isAuthRelated = host.contains("login5") || host.contains("apresolve") || From c8aa3478131637de61d3d36e3f960e25d48fcc1b Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 20:47:19 +0800 Subject: [PATCH 25/26] Update Tweak.x.swift --- Sources/EeveeSpotify/Tweak.x.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index 220618dd..435309b1 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -222,6 +222,9 @@ struct EeveeSpotify: Tweak { init() { eeveeBreadcrumb("Tweak init() entered") + // Reset per-launch bootstrap state; this MUST NOT persist across restarts. + // Otherwise Spotify can get stuck on splash because bootstrap is cancelled. + UserDefaults.hasPatchedBootstrap = false // Global kill-switch for debugging “instant crash / no logs”. // If setting this makes Spotify launch, the crash is definitely in one of our hook activations. From 134f59ea5d30ba38884150598cd2daa448c30a16 Mon Sep 17 00:00:00 2001 From: jaydenjcpy Date: Mon, 20 Apr 2026 20:47:45 +0800 Subject: [PATCH 26/26] Update SessionProtection.x.swift --- Sources/EeveeSpotify/SessionProtection.x.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/EeveeSpotify/SessionProtection.x.swift b/Sources/EeveeSpotify/SessionProtection.x.swift index 2fb5af82..6dc27dfd 100644 --- a/Sources/EeveeSpotify/SessionProtection.x.swift +++ b/Sources/EeveeSpotify/SessionProtection.x.swift @@ -342,7 +342,9 @@ class URLSessionTaskResumeHook: ClassHook { // If we've already patched bootstrap once in this session, block any subsequent // bootstrap calls. Some 9.1.x builds perform a second bootstrap very early during // an internal session re-init, and that second response can overwrite our premium state. - if path.contains("bootstrap/v1/bootstrap"), UserDefaults.hasPatchedBootstrap { + // Only cancel *subsequent* bootstrap calls in the same launch cycle. + // Never cancel the initial bootstrap during startup, or Spotify can hang on splash. + if path.contains("bootstrap/v1/bootstrap"), UserDefaults.hasPatchedBootstrap, elapsed > 5 { writeDebugLog("[NET] Cancelled bootstrap re-fetch at \(elapsedInt)s") task.cancel() return