diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..13e35198 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,130 @@ +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: "" + 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 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + + - 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 diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index 60e18e66..b392e9d3 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 @@ -59,7 +61,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 @@ -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,20 @@ 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") + UserDefaults.hasPatchedBootstrap = true + 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) diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift index dd1ed548..84e3071a 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift @@ -9,6 +9,8 @@ private func showHavePremiumPopUp() { } class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDelegate { + // This hook is the *core* of premium patching (intercepts bootstrap and mutates UCS). + typealias Group = PremiumBootstrapGroup static var targetName: String { switch EeveeSpotify.hookTarget { case .lastAvailableiOS14: return "SPTCoreURLSessionDataDelegate" @@ -82,6 +84,8 @@ class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDe } if UserDefaults.patchType == .requests { + writeDebugLog("[BOOTSTRAP] Patching bootstrap UCS response") + UserDefaults.hasPatchedBootstrap = true modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) orig.URLSession( @@ -91,6 +95,7 @@ class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDe ) } else { + writeDebugLog("[BOOTSTRAP] Passing through unmodified bootstrap (patchType=\(UserDefaults.patchType))") orig.URLSession(session, dataTask: task, didReceiveData: buffer) } 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 = [ 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?) { 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() diff --git a/Sources/EeveeSpotify/SessionProtection.x.swift b/Sources/EeveeSpotify/SessionProtection.x.swift index 0de63c66..6dc27dfd 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() { @@ -333,6 +339,17 @@ 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. + // 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 + } + // Log auth-related requests for diagnostics let isAuthRelated = host.contains("login5") || host.contains("apresolve") || 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) diff --git a/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift b/Sources/EeveeSpotify/Shared/Models/Extensions/UserDefaults+Extension.swift index 29df594c..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 { @@ -36,7 +37,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) @@ -61,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) diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index 6ff5db48..435309b1 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -1,6 +1,8 @@ import Orion import EeveeSpotifyC import UIKit +import Foundation +import ObjectiveC.runtime func writeDebugLog(_ message: String) { // Log to system console @@ -43,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 { } @@ -79,6 +86,118 @@ 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(minimal: Bool) { + func log(_ msg: String) { + NSLog("[EeveeSpotify][SessionProtect] %@", msg) + } + + @inline(__always) + func classHasInstanceMethod(_ cls: AnyClass, _ sel: Selector) -> Bool { + 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] = [ + Selector(("logout")), + Selector(("logoutWithReason:")), + Selector(("callSessionDidLogoutOnDelegateWithReason:")), + Selector(("logWillLogoutEventWithLogoutReason:")), + Selector(("destroy")), + ] + let ok = required.allSatisfy { classHasInstanceMethod(cls, $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 { classHasInstanceMethod(cls, $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 { classHasInstanceMethod(cls, $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"), classHasInstanceMethod(cls, #selector(URLSessionTask.resume)) { + SessionLogoutNetworkHookGroup().activate() + log("Activated URLSessionTask hooks") + } else { + log("Skipped URLSessionTask hooks (missing selector)") + } +} + +// 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" @@ -102,8 +221,27 @@ struct EeveeSpotify: Tweak { } init() { - // Activate session logout protection first (all versions) - SessionLogoutHookGroup().activate() + 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. + 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. + 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 ?? "?" @@ -143,23 +281,77 @@ struct EeveeSpotify: Tweak { // For 9.1.x, activate premium patching and lyrics if EeveeSpotify.hookTarget == .v91 { - // Premium patching + // Premium patching (9.1.x) + // Always activate the *bootstrap interceptor*; it is required for premium patching. if UserDefaults.patchType.isPatching { - BasePremiumPatchingGroup().activate() + PremiumBootstrapGroup().activate() + writeDebugLog("[INIT] Activated PremiumBootstrapGroup") + + // Optional UI hooks (safe-gated) + if let hub = NSClassFromString("HUBViewModelBuilderImplementation"), + class_getInstanceMethod(hub, Selector(("addJSONDictionary:"))) != nil { + PremiumUIHooksGroup().activate() + } else { + writeDebugLog("[INIT] Skipped PremiumUIHooksGroup (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 { + + 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 settings integration (ProfileSettingsSection API mismatch)") + } // Also activate the banner for 9.1.x to ensure visibility if menu is missing // V91SettingsIntegrationGroup().activate() @@ -230,7 +422,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() } }