From 5ba2b4a659cf905f049edcc314c2a30db87b3521 Mon Sep 17 00:00:00 2001 From: eevee Date: Fri, 9 Aug 2024 13:46:38 +0300 Subject: [PATCH] real --- .../DataLoaderServiceHooks.x.swift | 75 +++++++------- .../EeveeSpotify/Lyrics/CustomLyrics.x.swift | 10 +- .../Lyrics/Models/LyricsDto.swift | 4 +- .../Lyrics/Models/LyricsError.swift | 12 +-- .../MusixmatchLyricsRepository.swift | 11 +- .../Models/Extensions/String+Extension.swift | 9 +- .../Extensions/UserDefaults+Extension.swift | 2 +- ...=> DynamicPremium+ModifyBootstrap.x.swift} | 65 +++++++----- ...> DynamicPremium+ModifyingFunctions.swift} | 2 - .../Premium/Helpers/BundleHelper.swift | 36 +++---- .../Premium/Helpers/OfflineHelper.swift | 76 ++++---------- .../Premium/Models/PatchType.swift | 5 +- .../Models/SpotifySessionDelegate.swift | 22 ++++ .../Premium/OfflineObserver.swift | 48 --------- .../Premium/ServerSidedReminder.x.swift | 12 +-- .../EeveeSettingsView+VersionSection.swift | 7 +- .../Settings/Views/EeveeSettingsView.swift | 29 ++++-- .../EeveeLyricsSettingsView+Extension.swift | 10 +- ...ricsSettingsView+LyricsSourceSection.swift | 29 ++---- .../Sections/EeveeLyricsSettingsView.swift | 16 ++- .../Sections/EeveePatchingSettingsView.swift | 44 ++------ .../Views/Sections/EeveeUISettingsView.swift | 20 ++-- Sources/EeveeSpotify/Tweak.x.swift | 77 +------------- .../EeveeSpotify.bundle/Info.plist | 8 ++ .../en.lproj/Localizable.strings | 98 ++++++++++++++++++ .../EeveeSpotify.bundle/premiumblank.bnk | Bin 15038 -> 0 bytes 26 files changed, 323 insertions(+), 404 deletions(-) rename Sources/EeveeSpotify/Premium/{DynamicPremium.x.swift => DynamicPremium+ModifyBootstrap.x.swift} (79%) rename Sources/EeveeSpotify/Premium/{DynamicPremium+ModifyFunctions.swift => DynamicPremium+ModifyingFunctions.swift} (99%) create mode 100644 Sources/EeveeSpotify/Premium/Models/SpotifySessionDelegate.swift delete mode 100644 Sources/EeveeSpotify/Premium/OfflineObserver.swift create mode 100644 layout/Library/Application Support/EeveeSpotify.bundle/Info.plist create mode 100644 layout/Library/Application Support/EeveeSpotify.bundle/en.lproj/Localizable.strings delete mode 100644 layout/Library/Application Support/EeveeSpotify.bundle/premiumblank.bnk diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index 4ef53c6f..eeb9442c 100644 --- a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift +++ b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift @@ -1,8 +1,7 @@ import Foundation import Orion -class SPTDataLoaderServiceHook: ClassHook { - +class SPTDataLoaderServiceHook: ClassHook, SpotifySessionDelegate { static let targetName = "SPTDataLoaderService" // orion:new @@ -23,48 +22,46 @@ class SPTDataLoaderServiceHook: ClassHook { return } - if error == nil && shouldModify(url) { - - if let buffer = URLSessionHelper.shared.obtainData(for: url) { - - if url.isLyrics { - - do { - orig.URLSession( - session, - dataTask: task, - didReceiveData: try getLyricsForCurrentTrack( - originalLyrics: try? Lyrics(serializedData: buffer) - ) - ) - - orig.URLSession(session, task: task, didCompleteWithError: nil) - } - catch { - orig.URLSession(session, task: task, didCompleteWithError: error) - } - - return - } - + if error == nil, + shouldModify(url), + let buffer = URLSessionHelper.shared.obtainData(for: url) + { + if url.isLyrics { do { - var customizeMessage = try CustomizeMessage(serializedData: buffer) - modifyRemoteConfiguration(&customizeMessage.response) - orig.URLSession( session, dataTask: task, - didReceiveData: try customizeMessage.serializedData() + didReceiveData: try getLyricsForCurrentTrack( + originalLyrics: try? Lyrics(serializedData: buffer) + ) ) orig.URLSession(session, task: task, didCompleteWithError: nil) - - NSLog("[EeveeSpotify] Modified customize data") - return } catch { - NSLog("[EeveeSpotify] Unable to modify customize data: \(error)") + orig.URLSession(session, task: task, didCompleteWithError: error) } + + return + } + + do { + var customizeMessage = try CustomizeMessage(serializedData: buffer) + modifyRemoteConfiguration(&customizeMessage.response) + + orig.URLSession( + session, + dataTask: task, + didReceiveData: try customizeMessage.serializedData() + ) + + orig.URLSession(session, task: task, didCompleteWithError: nil) + + NSLog("[EeveeSpotify] Modified customize data") + return + } + catch { + NSLog("[EeveeSpotify] Unable to modify customize data: \(error)") } } @@ -76,12 +73,16 @@ class SPTDataLoaderServiceHook: ClassHook { _ session: URLSession, dataTask task: URLSessionDataTask, didReceiveResponse response: HTTPURLResponse, - completionHandler handler: Any + completionHandler handler: @escaping (URLSession.ResponseDisposition) -> Void ) { - let url = response.url! + guard + let request = task.currentRequest, + let url = request.url + else { + return + } if url.isLyrics, response.statusCode != 200 { - let okResponse = HTTPURLResponse( url: url, statusCode: 200, diff --git a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift index 243f92ef..8f39e16d 100644 --- a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift +++ b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift @@ -113,7 +113,7 @@ class LyricsOnlyViewControllerHook: ClassHook { text.append( Dynamic.SPTEncoreAttributedString.alloc(interface: SPTEncoreAttributedString.self) .initWithString( - "\nFallback: \(description)", + "\n\("fallback_attribute".localized): \(description)", typeStyle: typeStyle, attributes: attributes ) @@ -124,7 +124,7 @@ class LyricsOnlyViewControllerHook: ClassHook { text.append( Dynamic.SPTEncoreAttributedString.alloc(interface: SPTEncoreAttributedString.self) .initWithString( - "\nRomanized", + "\n\("romanized_attribute".localized)", typeStyle: typeStyle, attributes: attributes ) @@ -182,10 +182,9 @@ private func loadLyricsForCurrentTrack() throws { case .InvalidMusixmatchToken: if !hasShownUnauthorizedPopUp { - PopUpHelper.showPopUp( delayed: false, - message: "The tweak is unable to load lyrics from Musixmatch due to Unauthorized error. Please check or update your Musixmatch token. If you use an iPad, you should get the token from the Musixmatch app for iPad.", + message: "musixmatch_unauthorized_popup".localized, buttonText: "OK" ) @@ -195,10 +194,9 @@ private func loadLyricsForCurrentTrack() throws { case .MusixmatchRestricted: if !hasShownRestrictedPopUp { - PopUpHelper.showPopUp( delayed: false, - message: "The tweak is unable to load lyrics from Musixmatch because they are restricted. It's likely a copyright issue due to the US IP address, so you should change it if you're in the US or use a VPN.", + message: "musixmatch_restricted_popup".localized, buttonText: "OK" ) diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift index 861e9cc7..acc080bc 100644 --- a/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift @@ -18,10 +18,10 @@ struct LyricsDto { if lines.isEmpty { lyricsData.lines = [ LyricsLine.with { - $0.content = "This song is instrumental." + $0.content = "song_is_instrumental".localized }, LyricsLine.with { - $0.content = "Let the music play..." + $0.content = "let_the_music_play".localized }, LyricsLine.with { $0.content = "" diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift index 75973ad7..47a18f11 100644 --- a/Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift @@ -10,12 +10,12 @@ enum LyricsError: Error, CustomStringConvertible { var description: String { switch self { - case .NoSuchSong: "No Song Found" - case .MusixmatchRestricted: "Restricted" - case .InvalidMusixmatchToken: "Unauthorized" - case .DecodingError: "Decoding Error" - case .NoCurrentTrack: "No Track Instance" - case .UnknownError: "Unknown Error" + case .NoSuchSong: "no_such_song".localized + case .MusixmatchRestricted: "musixmatch_restricted".localized + case .InvalidMusixmatchToken: "invalid_musixmatch_token".localized + case .DecodingError: "decoding_error".localized + case .NoCurrentTrack: "no_current_track".localized + case .UnknownError: "unknown_error".localized } } } diff --git a/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift index c1c7f044..97d626d7 100644 --- a/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift +++ b/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift @@ -187,8 +187,8 @@ class MusixmatchLyricsRepository: LyricsRepository { let subtitleTranslatedBody = subtitleTranslated["subtitle_body"] as? String, let subtitlesTranslated = try? JSONDecoder().decode( [MusixmatchSubtitle].self, from: subtitleTranslatedBody.data(using: .utf8)! - ) { - + ) + { if selectedLanguage == romanizationLanguage { romanized = true @@ -206,12 +206,7 @@ class MusixmatchLyricsRepository: LyricsRepository { } } - if options.musixmatchLanguage.isEmpty - && options.romanization - && selectedLanguage != romanizationLanguage { - - selectedLanguage = romanizationLanguage - + if options.romanization && selectedLanguage != romanizationLanguage { if let translations = try? getTranslations( query.spotifyTrackId, selectedLanguage: romanizationLanguage diff --git a/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift index 2a7184fc..21791601 100644 --- a/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift +++ b/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift @@ -2,10 +2,17 @@ import Foundation import NaturalLanguage extension String { - static func ~= (lhs: String, rhs: String) -> Bool { lhs.firstMatch(rhs) != nil } + + var localized: String { + BundleHelper.shared.localizedString(self) + } + + func localizeWithFormat(_ arguments: CVarArg...) -> String{ + String(format: self.localized, arguments: arguments) + } var range: NSRange { NSRange(self.startIndex..., in: self) diff --git a/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift index 7b118109..5b62f529 100644 --- a/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift +++ b/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift @@ -88,7 +88,7 @@ extension UserDefaults { static var patchType: PatchType { get { if let rawValue = defaults.object(forKey: patchTypeKey) as? Int { - return PatchType(rawValue: rawValue)! + return PatchType(rawValue: rawValue) ?? .requests } return .notSet diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium.x.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift similarity index 79% rename from Sources/EeveeSpotify/Premium/DynamicPremium.x.swift rename to Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift index 2acbc99c..a04e3207 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium.x.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift @@ -3,14 +3,46 @@ import Orion private func showHavePremiumPopUp() { PopUpHelper.showPopUp( delayed: true, - message: "It looks like you have an active Premium subscription, so the tweak won't patch the data or restrict the use of Premium server-sided features. You can manage this in the EeveeSpotify settings.", - buttonText: "OK" + message: "have_premium_popup".localized, + buttonText: "ok".localized ) } -class SPTCoreURLSessionDataDelegateHook: ClassHook { +class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDelegate { + static var targetName: String { + EeveeSpotify.isOldSpotifyVersion + ? "SPTCoreURLSessionDataDelegate" + : "SPTDataLoaderService" + } + + func URLSession( + _ session: URLSession, + dataTask task: URLSessionDataTask, + didReceiveResponse response: HTTPURLResponse, + completionHandler handler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + orig.URLSession(session, dataTask: task, didReceiveResponse: response, completionHandler: handler) + } - static let targetName = "SPTCoreURLSessionDataDelegate" + func URLSession( + _ session: URLSession, + dataTask task: URLSessionDataTask, + didReceiveData data: Data + ) { + guard + let request = task.currentRequest, + let url = request.url + else { + return + } + + if url.isBootstrap { + URLSessionHelper.shared.setOrAppend(data, for: url) + return + } + + orig.URLSession(session, dataTask: task, didReceiveData: data) + } func URLSession( _ session: URLSession, @@ -23,16 +55,14 @@ class SPTCoreURLSessionDataDelegateHook: ClassHook { else { return } - + if error == nil && url.isBootstrap { - let buffer = URLSessionHelper.shared.obtainData(for: url)! do { var bootstrapMessage = try BootstrapMessage(serializedData: buffer) if UserDefaults.patchType == .notSet { - if bootstrapMessage.attributes["type"]?.stringValue == "premium" { UserDefaults.patchType = .disabled showHavePremiumPopUp() @@ -46,7 +76,6 @@ class SPTCoreURLSessionDataDelegateHook: ClassHook { } if UserDefaults.patchType == .requests { - modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) orig.URLSession( @@ -71,24 +100,4 @@ class SPTCoreURLSessionDataDelegateHook: ClassHook { orig.URLSession(session, task: task, didCompleteWithError: error) } - - func URLSession( - _ session: URLSession, - dataTask task: URLSessionDataTask, - didReceiveData data: Data - ) { - guard - let request = task.currentRequest, - let url = request.url - else { - return - } - - if url.isBootstrap { - URLSessionHelper.shared.setOrAppend(data, for: url) - return - } - - orig.URLSession(session, dataTask: task, didReceiveData: data) - } } diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyFunctions.swift b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift similarity index 99% rename from Sources/EeveeSpotify/Premium/DynamicPremium+ModifyFunctions.swift rename to Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift index d86e1a30..70835645 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyFunctions.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift @@ -1,7 +1,6 @@ import Foundation func modifyRemoteConfiguration(_ configuration: inout UcsResponse) { - if UserDefaults.overwriteConfiguration { configuration.resolve.configuration = try! BundleHelper.shared.resolveConfiguration() } @@ -10,7 +9,6 @@ func modifyRemoteConfiguration(_ configuration: inout UcsResponse) { } func modifyAttributes(_ attributes: inout [String: AccountAttribute]) { - attributes["type"] = AccountAttribute.with { $0.stringValue = "premium" } diff --git a/Sources/EeveeSpotify/Premium/Helpers/BundleHelper.swift b/Sources/EeveeSpotify/Premium/Helpers/BundleHelper.swift index 6bb5ef5b..3377552f 100755 --- a/Sources/EeveeSpotify/Premium/Helpers/BundleHelper.swift +++ b/Sources/EeveeSpotify/Premium/Helpers/BundleHelper.swift @@ -3,38 +3,32 @@ import SwiftUI import libroot class BundleHelper { - private let bundleName = "EeveeSpotify" - + private let bundle: Bundle static let shared = BundleHelper() - - private init() { + + private init() { self.bundle = Bundle( path: Bundle.main.path( - forResource: bundleName, + forResource: bundleName, ofType: "bundle" - ) + ) ?? jbRootPath("/Library/Application Support/\(bundleName).bundle") )! } - func uiImage(_ name: String) -> UIImage { - return UIImage( - contentsOfFile: self.bundle.path( - forResource: name, - ofType: "png" - )! - )! - } - - func premiumBlankData() throws -> Data { - return try Data( - contentsOf: self.bundle.url( - forResource: "premiumblank", - withExtension: "bnk" + func uiImage(_ name: String) -> UIImage { + return UIImage( + contentsOfFile: self.bundle.path( + forResource: name, + ofType: "png" )! - ) + )! + } + + func localizedString(_ key: String) -> String { + return bundle.localizedString(forKey: key, value: nil, table: nil) } func resolveConfiguration() throws -> ResolveConfiguration { diff --git a/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift b/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift index 3334463b..e7c7f113 100755 --- a/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift +++ b/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift @@ -1,73 +1,33 @@ import Foundation class OfflineHelper { - - static let persistentCachePath = FileManager.default.urls( + static private let applicationSupportPath = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ) .first! - .appendingPathComponent("PersistentCache") - - // - - static var offlineBnkPath: URL { - persistentCachePath.appendingPathComponent("offline.bnk") - } - - static var eeveeBnkPath: URL { - persistentCachePath.appendingPathComponent("eevee.bnk") - } - - static var offlineBnkData: Data { - get throws { try Data(contentsOf: offlineBnkPath) } - } - - static var eeveeBnkData: Data { - get throws { try Data(contentsOf: eeveeBnkPath) } - } - - // - - private static func writeOfflineBnkData(_ data: Data) throws { - try data.write(to: offlineBnkPath) - } - - private static func writeEeveeBnkData(_ data: Data) throws { - try data.write(to: eeveeBnkPath) - } - + // - - static func restoreFromEeveeBnk() throws { - try writeOfflineBnkData(try eeveeBnkData) - } - - static func backupToEeveeBnk() throws { - try writeEeveeBnkData(try offlineBnkData) - } - - static func patchOfflineBnk() throws { - - let fileData = try offlineBnkData - - let usernameLength = Int(fileData[8]) - let username = Data(fileData[9..<9 + usernameLength]) - - var blankData = try BundleHelper.shared.premiumBlankData() - - blankData.insert(UInt8(usernameLength), at: 8) - blankData.insert(contentsOf: username, at: 9) - - try writeOfflineBnkData(blankData) - } + + static private let persistentCachePath = applicationSupportPath + .appendingPathComponent("PersistentCache") + + static private let remoteConfigPath = applicationSupportPath + .appendingPathComponent("remote-config") // - static func resetPersistentCache() throws { + static private func resetPersistentCache() throws { try FileManager.default.removeItem(at: self.persistentCachePath) } - static func resetOfflineBnk() throws { - try FileManager.default.removeItem(at: self.offlineBnkPath) + static private func resetRemoteConfig() throws { + try FileManager.default.removeItem(at: self.remoteConfigPath) + } + + // + + static func resetData() { + try? resetPersistentCache() + try? resetRemoteConfig() } } diff --git a/Sources/EeveeSpotify/Premium/Models/PatchType.swift b/Sources/EeveeSpotify/Premium/Models/PatchType.swift index 2fdcbff3..99165200 100644 --- a/Sources/EeveeSpotify/Premium/Models/PatchType.swift +++ b/Sources/EeveeSpotify/Premium/Models/PatchType.swift @@ -3,10 +3,7 @@ import Foundation enum PatchType: Int { case notSet case disabled - case offlineBnk case requests - var isPatching: Bool { - self == .requests || self == .offlineBnk - } + var isPatching: Bool { self == .requests } } diff --git a/Sources/EeveeSpotify/Premium/Models/SpotifySessionDelegate.swift b/Sources/EeveeSpotify/Premium/Models/SpotifySessionDelegate.swift new file mode 100644 index 00000000..28065377 --- /dev/null +++ b/Sources/EeveeSpotify/Premium/Models/SpotifySessionDelegate.swift @@ -0,0 +1,22 @@ +import Foundation + +protocol SpotifySessionDelegate { + func URLSession( + _ session: URLSession, + task: URLSessionDataTask, + didCompleteWithError error: Error? + ) + + func URLSession( + _ session: URLSession, + dataTask task: URLSessionDataTask, + didReceiveData data: Data + ) + + func URLSession( + _ session: URLSession, + dataTask task: URLSessionDataTask, + didReceiveResponse response: HTTPURLResponse, + completionHandler handler: @escaping (URLSession.ResponseDisposition) -> Void + ) +} diff --git a/Sources/EeveeSpotify/Premium/OfflineObserver.swift b/Sources/EeveeSpotify/Premium/OfflineObserver.swift deleted file mode 100644 index e7eead70..00000000 --- a/Sources/EeveeSpotify/Premium/OfflineObserver.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import UIKit - -class OfflineObserver: NSObject, NSFilePresenter { - - var presentedItemURL: URL? - var presentedItemOperationQueue: OperationQueue - - override init() { - presentedItemURL = OfflineHelper.offlineBnkPath - presentedItemOperationQueue = .main - } - - func presentedItemDidChange() { - - let productState = HookedInstances.productState! - - if productState.stringForKey("type") == "premium" { - -// if productState.stringForKey("shuffle") == "0" { -// return -// } - - do { - try OfflineHelper.backupToEeveeBnk() - NSLog("[EeveeSpotify] Settings has changed, updated eevee.bnk") - } - catch { - NSLog("[EeveeSpotify] Unable to update eevee.bnk: \(error)") - } - - return - } - - PopUpHelper.showPopUp( - message: "Spotify has just reloaded user data, and you've been switched to the Free plan. It's fine; simply restart the app, and the tweak will patch the data again. If this doesn't work, there might be a problem with the cached data. You can reset it and restart the app. Note: after resetting, you need to restart the app twice. You can also manage the Premium patching method in the EeveeSpotify settings.", - buttonText: "Restart App", - secondButtonText: "Reset Data and Restart App", - onPrimaryClick: { - exitApplication() - }, - onSecondaryClick: { - try! OfflineHelper.resetPersistentCache() - exitApplication() - } - ) - } -} diff --git a/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift b/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift index 5393e5f6..d4de6ab1 100644 --- a/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift +++ b/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift @@ -4,15 +4,13 @@ import UIKit struct ServerSidedReminder: HookGroup { } class StreamQualitySettingsSectionHook: ClassHook { - typealias Group = ServerSidedReminder static let targetName = "StreamQualitySettingsSection" func shouldResetSelection() -> Bool { - PopUpHelper.showPopUp( - message: "Very high audio quality is server-sided and is not available with this tweak.", - buttonText: "OK" + message: "high_audio_quality_popup".localized, + buttonText: "ok".localized ) return true @@ -23,13 +21,12 @@ class StreamQualitySettingsSectionHook: ClassHook { private func showOfflineModePopUp() { PopUpHelper.showPopUp( - message: "Native playlist downloading is server-sided and is not available with this tweak. You can download podcast episodes though.", - buttonText: "OK" + message: "playlist_downloading_popup".localized, + buttonText: "ok".localized ) } class FTPDownloadActionHook: ClassHook { - typealias Group = ServerSidedReminder static let targetName = "ListUXPlatform_FreeTierPlaylistImpl.FTPDownloadAction" @@ -39,7 +36,6 @@ class FTPDownloadActionHook: ClassHook { } class UIButtonHook: ClassHook { - typealias Group = ServerSidedReminder func setHighlighted(_ highlighted: Bool) { diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+VersionSection.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+VersionSection.swift index 9546876b..09eb0fb9 100644 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+VersionSection.swift +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+VersionSection.swift @@ -1,9 +1,7 @@ import SwiftUI extension EeveeSettingsView { - func loadVersion() async throws { - let (data, _) = try await URLSession.shared.data( from: URL(string: "https://api.github.com/repos/whoeevee/EeveeSpotify/releases/latest")! ) @@ -24,11 +22,10 @@ extension EeveeSettingsView { } @ViewBuilder func VersionSection() -> some View { - Section { if isUpdateAvailable { Link( - "Update Available", + "update_available".localized, destination: URL(string: "https://github.com/whoeevee/EeveeSpotify/releases")! ) } @@ -39,7 +36,7 @@ extension EeveeSettingsView { if latestVersion.isEmpty { HStack(spacing: 10) { ProgressView() - Text("Checking for Update...") + Text("checking_for_update".localized) } } } diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift index 6cd90463..1f8c857a 100644 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift @@ -2,7 +2,6 @@ import SwiftUI import UIKit struct EeveeSettingsView: View { - let navigationController: UINavigationController @State var latestVersion = "" @@ -19,7 +18,6 @@ struct EeveeSettingsView: View { var body: some View { List { - VersionSection() if !hasShownCommonIssuesTip { @@ -34,43 +32,52 @@ struct EeveeSettingsView: View { // Button { - pushSettingsController(with: EeveePatchingSettingsView(), title: "Patching") + pushSettingsController( + with: EeveePatchingSettingsView(), + title: "patching".localized + ) } label: { NavigationSectionView( color: .orange, - title: "Patching", + title: "patching".localized, imageSystemName: "hammer.fill" ) } Button { - pushSettingsController(with: EeveeLyricsSettingsView(), title: "Lyrics") + pushSettingsController( + with: EeveeLyricsSettingsView(), + title: "lyrics".localized + ) } label: { NavigationSectionView( color: .blue, - title: "Lyrics", + title: "lyrics".localized, imageSystemName: "quote.bubble.fill" ) } Button { - pushSettingsController(with: EeveeUISettingsView(), title: "Customization") + pushSettingsController( + with: EeveeUISettingsView(), + title: "customization".localized + ) } label: { NavigationSectionView( color: Color(hex: "#64D2FF"), - title: "Customization", + title: "customization".localized, imageSystemName: "paintpalette.fill" ) } // - Section(footer: Text("Clear cached data and restart the app.")) { + Section(footer: Text("reset_data_description".localized)) { Button { - try! OfflineHelper.resetPersistentCache() + OfflineHelper.resetData() exitApplication() } label: { - Text("Reset Data") + Text("reset_data".localized) } } } diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift index e61680ff..5825c882 100644 --- a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift @@ -3,7 +3,6 @@ import SwiftUI extension EeveeLyricsSettingsView { func getMusixmatchToken(_ input: String) -> String? { - if let match = input.firstMatch("\\[UserToken\\]: ([a-f0-9]+)"), let tokenRange = Range(match.range(at: 1), in: input) { return String(input[tokenRange]) @@ -16,10 +15,9 @@ extension EeveeLyricsSettingsView { } func showMusixmatchTokenAlert(_ oldSource: LyricsSource) { - let alert = UIAlertController( - title: "Enter User Token", - message: "In order to use Musixmatch, you need to retrieve your user token from the official app. Download Musixmatch from the App Store, sign up, then go to Settings > Get help > Copy debug info, and paste it here. You can also extract the token using MITM.", + title: "enter_user_token".localized, + message: "enter_user_token_message".localized, preferredStyle: .alert ) @@ -29,11 +27,11 @@ extension EeveeLyricsSettingsView { textField.placeholder = "---- Debug Info ---- [Device]: iPhone" } - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + alert.addAction(UIAlertAction(title: "cancel".localized, style: .cancel) { _ in lyricsSource = oldSource }) - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + alert.addAction(UIAlertAction(title: "ok".localized, style: .default) { _ in let text = alert.textFields!.first!.text! guard let token = getMusixmatchToken(text) else { diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift index c762c57a..7e1fba6f 100644 --- a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift @@ -3,26 +3,15 @@ import SwiftUI extension EeveeLyricsSettingsView { func lyricsSourceFooter() -> some View { - var text = """ -You can select the lyrics source you prefer. - -Genius: Offers the best quality lyrics, provides the most songs, and updates lyrics the fastest. Does not and will never be time-synced. - -LRCLIB: The most open service, offering time-synced lyrics. However, it lacks lyrics for many songs. - -Musixmatch: The service Spotify uses. Provides time-synced lyrics for many songs, but you'll need a user token to use this source. -""" + var text = "lyrics_source_description".localized if Locale.isInRegion("JP", orHasLanguage: "ja") { text.append("\n\n") - text.append("PetitLyrics: Offers plenty of time-synced Japanese and some international lyrics.") + text.append("petitlyrics_description".localized) } text.append("\n\n") - - text.append(""" -If the tweak is unable to find a song or process the lyrics, you'll see a "Couldn't load the lyrics for this song" message. The lyrics might be wrong for some songs when using Genius due to how the tweak searches songs. I've made it work in most cases. -""") + text.append("lyrics_additional_info".localized) return Text(text) } @@ -30,7 +19,7 @@ If the tweak is unable to find a song or process the lyrics, you'll see a "Could @ViewBuilder func LyricsSourceSection() -> some View { Section(footer: lyricsSourceFooter()) { Picker( - "Lyrics Source", + "lyrics_source".localized, selection: $lyricsSource ) { Text("Genius").tag(LyricsSource.genius) @@ -45,9 +34,9 @@ If the tweak is unable to find a song or process the lyrics, you'll see a "Could VStack(alignment: .leading, spacing: 5) { - Text("Musixmatch User Token") + Text("musixmatch_user_token".localized) - TextField("Enter User Token or Paste Debug Info", text: $musixmatchToken) + TextField("user_token_placeholder".localized, text: $musixmatchToken) .foregroundColor(.gray) } .frame(maxWidth: .infinity, alignment: .leading) @@ -55,8 +44,9 @@ If the tweak is unable to find a song or process the lyrics, you'll see a "Could } .onChange(of: musixmatchToken) { input in - - if input.isEmpty { return } + if input.isEmpty { + return + } if let token = getMusixmatchToken(input) { UserDefaults.musixmatchToken = token @@ -68,7 +58,6 @@ If the tweak is unable to find a song or process the lyrics, you'll see a "Could } .onChange(of: lyricsSource) { [lyricsSource] newSource in - if newSource == .musixmatch && musixmatchToken.isEmpty { showMusixmatchTokenAlert(lyricsSource) return diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift index 0c2a4d17..406ca18d 100644 --- a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift @@ -11,21 +11,20 @@ struct EeveeLyricsSettingsView: View { var body: some View { List { - LyricsSourceSection() if lyricsSource != .genius { Section( - footer: Text("Load lyrics from Genius if there is a problem with \(lyricsSource).") + footer: Text("genius_fallback_description".localizeWithFormat(lyricsSource.description)) ) { Toggle( - "Genius Fallback", + "genius_fallback".localized, isOn: $geniusFallback ) if geniusFallback { Toggle( - "Show Fallback Reasons", + "show_fallback_reasons".localized, isOn: Binding( get: { UserDefaults.fallbackReasons }, set: { UserDefaults.fallbackReasons = $0 } @@ -37,9 +36,9 @@ struct EeveeLyricsSettingsView: View { // - Section(footer: Text("Display romanized lyrics for Japanese, Korean, and Chinese.")) { + Section(footer: Text("romanized_lyrics_description".localized)) { Toggle( - "Romanized Lyrics", + "romanized_lyrics".localized, isOn: $lyricsOptions.romanization ) } @@ -53,7 +52,7 @@ struct EeveeLyricsSettingsView: View { .foregroundColor(.yellow) } - Text("Musixmatch Lyrics Language") + Text("musixmatch_language".localized) Spacer() @@ -62,7 +61,7 @@ struct EeveeLyricsSettingsView: View { .foregroundColor(.gray) } } footer: { - Text("You can enter a 2-letter Musixmatch language code and see translated lyrics on Musixmatch if they are available.") + Text("musixmatch_language_description".localized) } } @@ -85,7 +84,6 @@ struct EeveeLyricsSettingsView: View { } .onChange(of: lyricsOptions) { lyricsOptions in - let selectedLanguage = lyricsOptions.musixmatchLanguage if selectedLanguage.isEmpty || selectedLanguage ~= "^[\\w\\d]{2}$" { diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift index 69be3672..f7889add 100644 --- a/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift @@ -7,64 +7,32 @@ struct EeveePatchingSettingsView: View { var body: some View { List { - Section(footer: patchType == .disabled ? nil : Text(""" - You can select the Premium patching method you prefer. App restart is required after changing. - - Static: The original method. On app start, the tweak composes cache data by inserting your username into a blank file with preset Premium parameters. When Spotify reloads user data, you'll be switched to the Free plan and see a popup with quick restart app and reset data actions. - - Dynamic: This method intercepts requests to load user data, deserializes it, and modifies the parameters in real-time. It's much more stable and is recommended. - - If you have an active Premium subscription, you can turn on Do Not Patch Premium. The tweak won't patch the data or restrict the use of Premium server-sided features. - """)) { + Section(footer: patchType == .disabled ? nil : Text("patching_description".localized)) { Toggle( - "Do Not Patch Premium", + "do_not_patch_premium".localized, isOn: Binding( get: { patchType == .disabled }, set: { patchType = $0 ? .disabled : .requests } ) ) - - if patchType != .disabled { - Picker( - "Patching Method", - selection: $patchType - ) { - Text("Static").tag(PatchType.offlineBnk) - Text("Dynamic").tag(PatchType.requests) - } - } } .onChange(of: patchType) { newPatchType in - UserDefaults.patchType = newPatchType - - do { - try OfflineHelper.resetOfflineBnk() - } - catch { - NSLog("Unable to reset offline.bnk: \(error)") - } + OfflineHelper.resetData() } .onChange(of: overwriteConfiguration) { overwriteConfiguration in - UserDefaults.overwriteConfiguration = overwriteConfiguration - - do { - try OfflineHelper.resetOfflineBnk() - } - catch { - NSLog("Unable to reset offline.bnk: \(error)") - } + OfflineHelper.resetData() } if patchType == .requests { Section( - footer: Text("Replace remote configuration with the dumped Premium one. It might fix some issues, such as appearing ads, but it's not guaranteed.") + footer: Text("overwrite_configuration_description".localized) ) { Toggle( - "Overwrite Configuration", + "overwrite_configuration".localized, isOn: $overwriteConfiguration ) } diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift index d598cdb9..f5cc2941 100644 --- a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift @@ -2,31 +2,26 @@ import SwiftUI import UIKit struct EeveeUISettingsView: View { - @State var lyricsColors = UserDefaults.lyricsColors var body: some View { List { Section( - header: Text("Lyrics Background Color"), - footer: Text(""" - If you turn on Display Original Colors, the lyrics will appear in the original Spotify colors for tracks that have them. - - You can set a static color or a normalization factor based on the extracted track cover's color. This factor determines how much dark colors are lightened and light colors are darkened. Generally, you will see lighter colors with a higher normalization factor. - """)) { + header: Text("lyrics_background_color_section".localized), + footer: Text("lyrics_background_color_section_description".localized)) { Toggle( - "Display Original Colors", + "display_original_colors".localized, isOn: $lyricsColors.displayOriginalColors ) Toggle( - "Use Static Color", + "use_static_color".localized, isOn: $lyricsColors.useStaticColor ) if lyricsColors.useStaticColor { ColorPicker( - "Static Color", + "static_color".localized, selection: Binding( get: { Color(hex: lyricsColors.staticColor) }, set: { lyricsColors.staticColor = $0.hexString } @@ -36,8 +31,7 @@ struct EeveeUISettingsView: View { } else { VStack(alignment: .leading, spacing: 5) { - - Text("Color Normalization Factor") + Text("color_normalization_factor".localized) Slider( value: $lyricsColors.normalizationFactor, @@ -54,7 +48,7 @@ struct EeveeUISettingsView: View { Section { Toggle( - "Dark PopUps", + "dark_popups".localized, isOn: Binding( get: { UserDefaults.darkPopUps }, set: { UserDefaults.darkPopUps = $0 } diff --git a/Sources/EeveeSpotify/Tweak.x.swift b/Sources/EeveeSpotify/Tweak.x.swift index f3b1f40e..fbba5a6b 100644 --- a/Sources/EeveeSpotify/Tweak.x.swift +++ b/Sources/EeveeSpotify/Tweak.x.swift @@ -2,7 +2,6 @@ import Orion import UIKit func exitApplication() { - UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in exit(EXIT_SUCCESS) @@ -15,78 +14,12 @@ struct EeveeSpotify: Tweak { static let isOldSpotifyVersion = NSClassFromString("Lyrics_NPVCommunicatorImpl.LyricsOnlyViewController") == nil init() { - - do { - - defer { - - if UserDefaults.darkPopUps { - DarkPopUps().activate() - } - - let patchType = UserDefaults.patchType - - if patchType.isPatching { - - if patchType == .offlineBnk { - NSFileCoordinator.addFilePresenter(OfflineObserver()) - } - - ServerSidedReminder().activate() - } - } - - switch UserDefaults.patchType { - - case .disabled: - NSLog("[EeveeSpotify] Not activating: patchType is disabled") - - case .offlineBnk: - do { - try OfflineHelper.restoreFromEeveeBnk() - - NSLog("[EeveeSpotify] Restored from eevee.bnk") - return - } - - catch CocoaError.fileReadNoSuchFile { - NSLog("[EeveeSpotify] Not restoring from eevee.bnk: doesn't exist") - } - - do { - try OfflineHelper.patchOfflineBnk() - try OfflineHelper.backupToEeveeBnk() - } - - catch CocoaError.fileReadNoSuchFile { - - NSLog("[EeveeSpotify] Not activating: offline.bnk doesn't exist") - - PopUpHelper.showPopUp( - delayed: true, - message: "Please log in and restart the app to get Premium.", - buttonText: "OK" - ) - } - - case .notSet: - NSLog("[EeveeSpotify] Patching method not set, resetting offline.bnk") - try? OfflineHelper.resetOfflineBnk() - - default: - break - } + if UserDefaults.darkPopUps { + DarkPopUps().activate() } - - catch { - - NSLog("[EeveeSpotify] Unable to apply tweak: \(error)") - - PopUpHelper.showPopUp( - delayed: true, - message: "Unable to apply tweak: \(error)", - buttonText: "OK" - ) + + if UserDefaults.patchType.isPatching { + ServerSidedReminder().activate() } } } diff --git a/layout/Library/Application Support/EeveeSpotify.bundle/Info.plist b/layout/Library/Application Support/EeveeSpotify.bundle/Info.plist new file mode 100644 index 00000000..6337ff78 --- /dev/null +++ b/layout/Library/Application Support/EeveeSpotify.bundle/Info.plist @@ -0,0 +1,8 @@ + + + + + CFBundleDevelopmentRegion + English + + diff --git a/layout/Library/Application Support/EeveeSpotify.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/EeveeSpotify.bundle/en.lproj/Localizable.strings new file mode 100644 index 00000000..4945971b --- /dev/null +++ b/layout/Library/Application Support/EeveeSpotify.bundle/en.lproj/Localizable.strings @@ -0,0 +1,98 @@ +/* MARK: Settings */ + +patching = "Patching"; +lyrics = "Lyrics"; +customization = "Customization"; + +reset_data = "Reset Data"; +reset_data_description = "Clear cached data and restart the app."; + +checking_for_update = "Checking for Update..."; +update_available = "Update Available"; + +cancel = "Cancel"; +ok = "OK"; + +// Patching + +do_not_patch_premium = "Do Not Patch Premium"; +patching_description = "The tweak intercepts requests to load user data, deserializes it, and modifies the parameters in real-time. + +If you have an active Premium subscription, you can turn on Do Not Patch Premium. The tweak won't patch the data or restrict the use of Premium server-sided features. App restart is required after changing."; + +overwrite_configuration = "Overwrite Configuration"; +overwrite_configuration_description = "Replace remote configuration with the dumped Premium one. It might fix some issues, such as appearing ads, but it's not guaranteed."; + +// Lyrics + +lyrics_source = "Lyrics Source"; +lyrics_source_description = "You can select the lyrics source you prefer. + +Genius: Offers the best quality lyrics, provides the most songs, and updates lyrics the fastest. Does not and will never be time-synced. + +LRCLIB: The most open service, offering time-synced lyrics. However, it lacks lyrics for many songs. + +Musixmatch: The service Spotify uses. Provides time-synced lyrics for many songs, but you'll need a user token to use this source."; +lyrics_additional_info = "If the tweak is unable to find a song or process the lyrics, you'll see a \"Couldn't load the lyrics for this song\" message. The lyrics might be wrong for some songs when using Genius due to how the tweak searches songs. I've made it work in most cases."; +petitlyrics_description = "PetitLyrics: Offers plenty of time-synced Japanese and some international lyrics."; + +musixmatch_user_token = "Musixmatch User Token"; +user_token_placeholder = "Enter User Token or Paste Debug Info"; + +enter_user_token = "Enter User Token"; +enter_user_token_message = "In order to use Musixmatch, you need to retrieve your user token from the official app. Download Musixmatch from the App Store, sign up, then go to Settings > Get help > Copy debug info, and paste it here. You can also extract the token using MITM."; + +genius_fallback = "Genius Fallback"; +genius_fallback_description = "Load lyrics from Genius if there is a problem with %@."; + +show_fallback_reasons = "Show Fallback Reasons"; + +romanized_lyrics = "Romanized Lyrics"; +romanized_lyrics_description = "Display romanized lyrics for Japanese, Korean, and Chinese."; + +musixmatch_language = "Musixmatch Lyrics Language"; +musixmatch_language_description = "You can enter a 2-letter Musixmatch language code and see translated lyrics on Musixmatch if they are available."; + +// UI + +lyrics_background_color_section = "Lyrics Background Color"; +lyrics_background_color_section_description = "If you turn on Display Original Colors, the lyrics will appear in the original Spotify colors for tracks that have them. + +You can set a static color or a normalization factor based on the extracted track cover's color. This factor determines how much dark colors are lightened and light colors are darkened. Generally, you will see lighter colors with a higher normalization factor."; + +display_original_colors = "Display Original Colors"; + +use_static_color = "Use Static Color"; +static_color = "Static Color"; + +color_normalization_factor = "Color Normalization Factor"; +dark_popups = "Dark PopUps"; + +/* MARK: Premium PopUps */ + +have_premium_popup = "It looks like you have an active Premium subscription, so the tweak won't patch the data or restrict the use of Premium server-sided features. You can manage this in the EeveeSpotify settings."; + +high_audio_quality_popup = "Very high audio quality is server-sided and is not available with this tweak."; +playlist_downloading_popup = "Native playlist downloading is server-sided and is not available with this tweak. You can download podcast episodes though."; + +/* MARK: Lyrics */ + +fallback_attribute = "Fallback"; +romanized_attribute = "Romanized"; + +musixmatch_unauthorized_popup = "The tweak is unable to load lyrics from Musixmatch due to Unauthorized error. Please check or update your Musixmatch token. If you use an iPad, you should get the token from the Musixmatch app for iPad."; +musixmatch_restricted_popup = "The tweak is unable to load lyrics from Musixmatch because they are restricted. It's likely a copyright issue due to the US IP address, so you should change it if you're in the US or use a VPN."; + +// Errors Titles + +no_such_song = "No Song Found"; +musixmatch_restricted = "Restricted"; +invalid_musixmatch_token = "Unauthorized"; +decoding_error = "Decoding Error"; +no_current_track = "No Track Instance"; +unknown_error = "Unknown Error"; + +// Instrumental Titles + +song_is_instrumental = "This song is instrumental."; +let_the_music_play = "Let the music play..."; diff --git a/layout/Library/Application Support/EeveeSpotify.bundle/premiumblank.bnk b/layout/Library/Application Support/EeveeSpotify.bundle/premiumblank.bnk deleted file mode 100644 index 8949c42a24b742cf53f50c6ff2bbb7c0c6d1dcf1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15038 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 d>D}FH{k>b!v+p8z