diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 10193919..407830a3 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -375,6 +375,7 @@ D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; D56915072AD5E91B00A2B747 /* Regex+Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */; }; + D56915092AD5F3E800A2B747 /* AltTests+Sources.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */; }; D5708417292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D570841A2924680D00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */; }; @@ -1037,6 +1038,7 @@ D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = ""; }; D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = ""; }; + D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = ""; }; D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = ""; }; @@ -2244,6 +2246,7 @@ isa = PBXGroup; children = ( D586D39A28EF58B0000E101F /* AltTests.swift */, + D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */, D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */, ); path = AltTests; @@ -3291,6 +3294,7 @@ buildActionMask = 2147483647; files = ( D586D39B28EF58B0000E101F /* AltTests.swift in Sources */, + D56915092AD5F3E800A2B747 /* AltTests+Sources.swift in Sources */, D5F5AF2E28FDD2EC00C938F5 /* TestErrors.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AltStoreCore/Model/DatabaseManager.swift b/AltStoreCore/Model/DatabaseManager.swift index 8577579b..b1d1299e 100644 --- a/AltStoreCore/Model/DatabaseManager.swift +++ b/AltStoreCore/Model/DatabaseManager.swift @@ -251,7 +251,7 @@ private extension DatabaseManager } // Make sure to always update source URL to be current. - altStoreSource.sourceURL = Source.altStoreSourceURL + try! altStoreSource.setSourceURL(Source.altStoreSourceURL) let storeApp: StoreApp diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index bd883dec..61fa9afd 100644 --- a/AltStoreCore/Model/Source.swift +++ b/AltStoreCore/Model/Source.swift @@ -11,11 +11,7 @@ import UIKit public extension Source { - #if ALPHA - static let altStoreIdentifier = Bundle.Info.appbundleIdentifier - #else - static let altStoreIdentifier = Bundle.Info.appbundleIdentifier - #endif + static let altStoreIdentifier = try! Source.sourceID(from: Source.altStoreSourceURL) #if STAGING @@ -202,7 +198,7 @@ public class Source: NSManagedObject, Fetchable, Decodable { /* Properties */ @NSManaged public var name: String - @NSManaged public var identifier: String + @NSManaged public private(set) var identifier: String @NSManaged public var sourceURL: URL /* Source Detail */ @@ -254,7 +250,6 @@ public class Source: NSManagedObject, Fetchable, Decodable private enum CodingKeys: String, CodingKey { case name - case identifier case sourceURL case subtitle case localizedDescription = "description" @@ -283,11 +278,8 @@ public class Source: NSManagedObject, Fetchable, Decodable do { - self.sourceURL = sourceURL - let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) - self.identifier = try container.decode(String.self, forKey: .identifier) // Optional Values self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) @@ -313,7 +305,6 @@ public class Source: NSManagedObject, Fetchable, Decodable for (index, app) in apps.enumerated() { - app.sourceIdentifier = self.identifier app.sortIndex = Int32(index) } self._apps = NSMutableOrderedSet(array: apps) @@ -321,7 +312,6 @@ public class Source: NSManagedObject, Fetchable, Decodable let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? [] for (index, item) in newsItems.enumerated() { - item.sourceIdentifier = self.identifier item.sortIndex = Int32(index) } @@ -343,6 +333,9 @@ public class Source: NSManagedObject, Fetchable, Decodable let featuredAppBundleIDs = try container.decodeIfPresent([String].self, forKey: .featuredApps) let featuredApps = featuredAppBundleIDs?.compactMap { appsByID[$0] } self.setFeaturedApps(featuredApps) + + // Updates identifier + apps & newsItems + try self.setSourceURL(sourceURL) } catch { @@ -397,6 +390,54 @@ public extension Source internal extension Source { + class func sourceID(from sourceURL: URL) throws -> String + { + // Based on https://encyclopedia.pub/entry/29841 + + guard var components = URLComponents(url: sourceURL, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: sourceURL]) } + + if components.scheme == nil && components.host == nil + { + // Special handling for URLs without explicit scheme & incorrectly assumed to have nil host (e.g. "altstore.io/my/path") + guard let updatedComponents = URLComponents(string: "https://" + sourceURL.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) } + components = updatedComponents + } + + // 1. Don't use percent encoding + guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) } + + // 2. Ignore scheme + var standardizedID = host + + // 3. Add port (if not default) + if let port = components.port, port != 80 && port != 443 + { + standardizedID += ":" + String(port) + } + + // 4. Add path without fragment or query parameters + // 5. Remove duplicate slashes + let path = components.path.replacingOccurrences(of: "//", with: "/") // Only remove duplicate slashes from path, not entire URL. + standardizedID += path // path has leading `/` + + // 6. Convert to lowercase + standardizedID = standardizedID.lowercased() + + // 7. Remove trailing `/` + if standardizedID.hasSuffix("/") + { + standardizedID.removeLast() + } + + // 8. Remove leading "www" + if standardizedID.hasPrefix("www.") + { + standardizedID.removeFirst(4) + } + + return standardizedID + } + func setFeaturedApps(_ featuredApps: [StoreApp]?) { // Explicitly update relationships for all apps to ensure featuredApps merges correctly. @@ -418,6 +459,27 @@ internal extension Source } } +public extension Source +{ + func setSourceURL(_ sourceURL: URL) throws + { + let identifier = try Source.sourceID(from: sourceURL) + + self.identifier = identifier + self.sourceURL = sourceURL + + for app in self.apps + { + app.sourceIdentifier = identifier + } + + for newsItem in self.newsItems + { + newsItem.sourceIdentifier = identifier + } + } +} + public extension Source { @nonobjc class func fetchRequest() -> NSFetchRequest diff --git a/AltTests/AltTests+Sources.swift b/AltTests/AltTests+Sources.swift new file mode 100644 index 00000000..b20d05f8 --- /dev/null +++ b/AltTests/AltTests+Sources.swift @@ -0,0 +1,159 @@ +// +// AltTests+Sources.swift +// AltTests +// +// Created by Riley Testut on 10/10/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import XCTest + +@testable import AltStoreCore + +extension AltTests +{ + func testSourceID() throws + { + let url = Source.altStoreSourceURL + + let sourceID = try Source.sourceID(from: url) + XCTAssertEqual(sourceID, "apps.altstore.io") + } + + @available(iOS 17, *) + func testSourceIDWithPercentEncoding() throws + { + let url = URL(string: "apple.com/MY invalid•path/")! + + let sourceID = try Source.sourceID(from: url) + XCTAssertEqual(sourceID, "apple.com/my invalid•path") + } + + func testSourceIDWithDifferentSchemes() throws + { + let url1 = URL(string: "http://rileytestut.com")! + let url2 = URL(string: "https://rileytestut.com")! + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "rileytestut.com") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithNonDefaultPort() throws + { + let url = URL(string: "http://localhost:8008/apps.json")! + + let sourceID = try Source.sourceID(from: url) + XCTAssertEqual(sourceID, "localhost:8008/apps.json") + } + + func testSourceIDWithFragmentsAndQueries() throws + { + var components = URLComponents(string: "https://disney.com/altstore/apps")! + components.fragment = "get started" + + components.queryItems = [URLQueryItem(name: "id", value: "1234")] + let url1 = components.url! + + components.queryItems = [URLQueryItem(name: "id", value: "5678")] + let url2 = components.url! + + XCTAssertNotEqual(url1, url2) + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "disney.com/altstore/apps") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithDuplicateSlashes() throws + { + let url1 = URL(string: "http://rileytestut.co.nz//secret/altstore//apps.json")! + let url2 = URL(string: "http://rileytestut.co.nz/secret/altstore/apps.json//")! + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "rileytestut.co.nz/secret/altstore/apps.json") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithMixedCase() throws + { + let href = "https://rileyTESTUT.co.nz/test/PATH/ApPs.json" + + let url1 = URL(string: href)! + let url2 = URL(string: href.lowercased())! + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "rileytestut.co.nz/test/path/apps.json") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithTrailingSlash() throws + { + let url1 = URL(string: "http://apps.altstore.io/")! + let url2 = URL(string: "http://apps.altstore.io")! + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "apps.altstore.io") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithLeadingWWW() throws + { + let url1 = URL(string: "http://www.GBA4iOSApp.com")! + let url2 = URL(string: "http://gba4iosapp.com")! + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "gba4iosapp.com") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithAllRules() throws + { + let url1 = URL(string: "fTp://WWW.apps.APPLE.com:4004//altstore apps/source.JSON?user=test@altstore.io#welcome//")! + let url2 = URL(string: "ftp://apps.apple.com:4004/altstore apps/source.json?user=anothertest@altstore.io#welcome")! + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "apps.apple.com:4004/altstore apps/source.json") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } + + func testSourceIDWithEmoji() throws + { + let url1 = URL(string: "http://xn--g5h5981o.com")! // 🤷‍♂️.com + let sourceID1 = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID1, "🤷♂.com") + + let url2 = URL(string: "http://www.xn--7r8h.io")! // www.💜.io + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID2, "💜.io") + } + + func testSourceIDWithRelativeURL() throws + { + let baseURL = URL(string: "https://rileytestut.com")! + let path = "altstore/apps.json" + + let url1 = URL(string: path, relativeTo: baseURL)! + let url2 = baseURL.appendingPathComponent(path) + + let sourceID = try Source.sourceID(from: url1) + XCTAssertEqual(sourceID, "rileytestut.com/altstore/apps.json") + + let sourceID2 = try Source.sourceID(from: url2) + XCTAssertEqual(sourceID, sourceID2) + } +}