mirror of
https://github.com/SideStore/SideStore.git
synced 2026-04-15 15:15:38 +02:00
Merge branch 'patreon'
This commit is contained in:
@@ -133,9 +133,9 @@
|
||||
BF66EE8C2501AEB2007EE018 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE8B2501AEB1007EE018 /* Keychain.swift */; };
|
||||
BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */; };
|
||||
BF66EE962501AEBC007EE018 /* ALTPatreonBenefitID.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */; };
|
||||
BF66EE972501AEBC007EE018 /* ALTAppPermissions.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */; };
|
||||
BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
BF66EE982501AEBC007EE018 /* ALTPatreonBenefitID.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */; };
|
||||
BF66EE9D2501AEC1007EE018 /* AppProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */; };
|
||||
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9C2501AEC1007EE018 /* Fetchable.swift */; };
|
||||
@@ -357,6 +357,7 @@
|
||||
D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; };
|
||||
D51AD28029356B8000967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; };
|
||||
D52A2F972ACB40F700BDF8E3 /* Logger+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */; };
|
||||
D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52B4ABE2AF183F0005991C3 /* WebViewController.swift */; };
|
||||
D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; };
|
||||
D52DD35E2AAA89A600A7F2B6 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D52DD35D2AAA89A600A7F2B6 /* AltSign-Dynamic */; };
|
||||
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */; };
|
||||
@@ -373,8 +374,12 @@
|
||||
D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */; };
|
||||
D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; };
|
||||
D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; };
|
||||
D552EB062AF453F900A3AB4D /* URL+Normalized.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552EB052AF453F900A3AB4D /* URL+Normalized.swift */; };
|
||||
D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; };
|
||||
D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */; };
|
||||
D557A4812AE85BB0007D0DCF /* Pledge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D557A4802AE85BB0007D0DCF /* Pledge.swift */; };
|
||||
D557A4832AE85DB7007D0DCF /* PledgeReward.swift in Sources */ = {isa = PBXBuildFile; fileRef = D557A4822AE85DB7007D0DCF /* PledgeReward.swift */; };
|
||||
D557A4852AE88227007D0DCF /* PledgeTier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D557A4842AE88227007D0DCF /* PledgeTier.swift */; };
|
||||
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 */; };
|
||||
@@ -418,6 +423,9 @@
|
||||
D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; };
|
||||
D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; };
|
||||
D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; };
|
||||
D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645202AF591980047D980 /* UTType+AltStore.swift */; };
|
||||
D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */; };
|
||||
D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645242AF5BC7F0047D980 /* UserAccount.swift */; };
|
||||
D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; };
|
||||
D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */; };
|
||||
D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */; };
|
||||
@@ -753,9 +761,9 @@
|
||||
BF66EE8B2501AEB1007EE018 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
|
||||
BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTAppPermissions.h; sourceTree = "<group>"; };
|
||||
BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTSourceUserInfoKey.h; sourceTree = "<group>"; };
|
||||
BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitType.m; sourceTree = "<group>"; };
|
||||
BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitID.m; sourceTree = "<group>"; };
|
||||
BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermissions.m; sourceTree = "<group>"; };
|
||||
BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitType.h; sourceTree = "<group>"; };
|
||||
BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitID.h; sourceTree = "<group>"; };
|
||||
BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTSourceUserInfoKey.m; sourceTree = "<group>"; };
|
||||
BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppProtocol.swift; sourceTree = "<group>"; };
|
||||
BF66EE9C2501AEC1007EE018 /* Fetchable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetchable.swift; sourceTree = "<group>"; };
|
||||
@@ -970,6 +978,7 @@
|
||||
D51AD27C29356B7B00967AAA /* ALTWrappedError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTWrappedError.h; sourceTree = "<group>"; };
|
||||
D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = "<group>"; };
|
||||
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = "<group>"; };
|
||||
D52B4ABE2AF183F0005991C3 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
|
||||
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = "<group>"; };
|
||||
D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = "<group>"; };
|
||||
D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -986,8 +995,12 @@
|
||||
D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = "<group>"; };
|
||||
D552EB052AF453F900A3AB4D /* URL+Normalized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalized.swift"; sourceTree = "<group>"; };
|
||||
D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = "<group>"; };
|
||||
D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = "<group>"; };
|
||||
D557A4802AE85BB0007D0DCF /* Pledge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pledge.swift; sourceTree = "<group>"; };
|
||||
D557A4822AE85DB7007D0DCF /* PledgeReward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeReward.swift; sourceTree = "<group>"; };
|
||||
D557A4842AE88227007D0DCF /* PledgeTier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeTier.swift; sourceTree = "<group>"; };
|
||||
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = "<group>"; };
|
||||
D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = "<group>"; };
|
||||
D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = "<group>"; };
|
||||
@@ -1028,6 +1041,9 @@
|
||||
D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = "<group>"; };
|
||||
D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = "<group>"; };
|
||||
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = "<group>"; };
|
||||
D5A645202AF591980047D980 /* UTType+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+AltStore.swift"; sourceTree = "<group>"; };
|
||||
D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = "<group>"; };
|
||||
D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = "<group>"; };
|
||||
D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = "<group>"; };
|
||||
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = "<group>"; };
|
||||
D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -1484,6 +1500,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF66EE8B2501AEB1007EE018 /* Keychain.swift */,
|
||||
D52B4ABE2AF183F0005991C3 /* WebViewController.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@@ -1496,8 +1513,8 @@
|
||||
D5893F812A141E4900E767CD /* KnownSource.swift */,
|
||||
BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */,
|
||||
BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */,
|
||||
BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */,
|
||||
BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */,
|
||||
BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */,
|
||||
BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */,
|
||||
BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */,
|
||||
BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */,
|
||||
);
|
||||
@@ -1518,11 +1535,13 @@
|
||||
BF66EE9F2501AEC5007EE018 /* Patreon */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF66EEA12501AEC5007EE018 /* PatreonAPI.swift */,
|
||||
D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */,
|
||||
BF66EEA02501AEC5007EE018 /* Benefit.swift */,
|
||||
BF66EEA22501AEC5007EE018 /* Campaign.swift */,
|
||||
BF66EEA12501AEC5007EE018 /* PatreonAPI.swift */,
|
||||
BF66EEA32501AEC5007EE018 /* Patron.swift */,
|
||||
BF66EEA42501AEC5007EE018 /* Tier.swift */,
|
||||
D5A645242AF5BC7F0047D980 /* UserAccount.swift */,
|
||||
);
|
||||
path = Patreon;
|
||||
sourceTree = "<group>";
|
||||
@@ -1543,13 +1562,13 @@
|
||||
D58916FD28C7C55C00E39C8B /* LoggedError.swift */,
|
||||
BF66EEC52501AECA007EE018 /* MergePolicy.swift */,
|
||||
BF66EEBF2501AECA007EE018 /* NewsItem.swift */,
|
||||
BF66EEC82501AECA007EE018 /* PatreonAccount.swift */,
|
||||
D5CA0C4A280E141900469595 /* ManagedPatron.swift */,
|
||||
BF66EEC32501AECA007EE018 /* RefreshAttempt.swift */,
|
||||
BF66EEC12501AECA007EE018 /* SecureValueTransformer.swift */,
|
||||
BF66EEAB2501AECA007EE018 /* Source.swift */,
|
||||
BF66EEC42501AECA007EE018 /* StoreApp.swift */,
|
||||
BF66EEC22501AECA007EE018 /* Team.swift */,
|
||||
D557A4862AE88232007D0DCF /* Patreon */,
|
||||
BF66EEAC2501AECA007EE018 /* Migrations */,
|
||||
);
|
||||
path = Model;
|
||||
@@ -1609,6 +1628,7 @@
|
||||
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */,
|
||||
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */,
|
||||
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */,
|
||||
D552EB052AF453F900A3AB4D /* URL+Normalized.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -1923,6 +1943,7 @@
|
||||
D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */,
|
||||
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */,
|
||||
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */,
|
||||
D5A645202AF591980047D980 /* UTType+AltStore.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -2168,6 +2189,17 @@
|
||||
path = "App Intents";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D557A4862AE88232007D0DCF /* Patreon */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF66EEC82501AECA007EE018 /* PatreonAccount.swift */,
|
||||
D557A4802AE85BB0007D0DCF /* Pledge.swift */,
|
||||
D557A4842AE88227007D0DCF /* PledgeTier.swift */,
|
||||
D557A4822AE85DB7007D0DCF /* PledgeReward.swift */,
|
||||
);
|
||||
path = Patreon;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D55FEC9C2A8FEC600057D6E6 /* Legacy */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -2328,7 +2360,7 @@
|
||||
files = (
|
||||
BF66EE822501AE50007EE018 /* AltStoreCore.h in Headers */,
|
||||
BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */,
|
||||
BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */,
|
||||
BF66EE982501AEBC007EE018 /* ALTPatreonBenefitID.h in Headers */,
|
||||
BFAECC5F2501B0BF00528F27 /* ALTConstants.h in Headers */,
|
||||
BFAECC5D2501B0BF00528F27 /* ALTConnection.h in Headers */,
|
||||
BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */,
|
||||
@@ -3036,12 +3068,15 @@
|
||||
D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */,
|
||||
BF66EED32501AECA007EE018 /* AltStore2ToAltStore3.xcmappingmodel in Sources */,
|
||||
BF66EEA52501AEC5007EE018 /* Benefit.swift in Sources */,
|
||||
D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */,
|
||||
BF66EED22501AECA007EE018 /* AltStore4ToAltStore5.xcmappingmodel in Sources */,
|
||||
D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */,
|
||||
D5189C022A01BC6800F44625 /* UserInfoValue.swift in Sources */,
|
||||
BFAECC5B2501B0A400528F27 /* Bundle+AltStore.swift in Sources */,
|
||||
BF66EECD2501AECA007EE018 /* StoreAppPolicy.swift in Sources */,
|
||||
BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */,
|
||||
BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */,
|
||||
D552EB062AF453F900A3AB4D /* URL+Normalized.swift in Sources */,
|
||||
BFAECC522501B0A400528F27 /* CodableError.swift in Sources */,
|
||||
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */,
|
||||
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */,
|
||||
@@ -3068,11 +3103,12 @@
|
||||
D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */,
|
||||
BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel in Sources */,
|
||||
D5FD4ECB2A9532960097BEE8 /* DatabaseManager+Async.swift in Sources */,
|
||||
D557A4832AE85DB7007D0DCF /* PledgeReward.swift in Sources */,
|
||||
D5893F802A1419E800E767CD /* NSManagedObjectContext+Conveniences.swift in Sources */,
|
||||
D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */,
|
||||
D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */,
|
||||
BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */,
|
||||
BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */,
|
||||
BF66EE962501AEBC007EE018 /* ALTPatreonBenefitID.m in Sources */,
|
||||
BFAECC5A2501B0A400528F27 /* NetworkConnection.swift in Sources */,
|
||||
D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */,
|
||||
BF66EEE92501AED0007EE018 /* JSONDecoder+Properties.swift in Sources */,
|
||||
@@ -3104,6 +3140,7 @@
|
||||
BF66EECC2501AECA007EE018 /* Source.swift in Sources */,
|
||||
BF66EED72501AECA007EE018 /* InstalledApp.swift in Sources */,
|
||||
D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */,
|
||||
D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */,
|
||||
D5185B822AE1E71D00646E33 /* Source13To14MigrationPolicy.swift in Sources */,
|
||||
BF66EECE2501AECA007EE018 /* InstalledAppPolicy.swift in Sources */,
|
||||
BF1FE359251A9FB000C3CE09 /* NSXPCConnection+MachServices.swift in Sources */,
|
||||
@@ -3115,6 +3152,8 @@
|
||||
BF66EED62501AECA007EE018 /* NewsItem.swift in Sources */,
|
||||
BF66EEA72501AEC5007EE018 /* Campaign.swift in Sources */,
|
||||
BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */,
|
||||
D557A4812AE85BB0007D0DCF /* Pledge.swift in Sources */,
|
||||
D557A4852AE88227007D0DCF /* PledgeTier.swift in Sources */,
|
||||
BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -3244,6 +3283,7 @@
|
||||
BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */,
|
||||
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */,
|
||||
BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */,
|
||||
D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */,
|
||||
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */,
|
||||
BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */,
|
||||
BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */,
|
||||
|
||||
@@ -87,8 +87,6 @@ class AppViewController: UIViewController
|
||||
self.bannerView.iconImageView.tintColor = self.app.tintColor
|
||||
self.bannerView.button.tintColor = self.app.tintColor
|
||||
self.bannerView.tintColor = self.app.tintColor
|
||||
|
||||
self.bannerView.configure(for: self.app)
|
||||
self.bannerView.accessibilityTraits.remove(.button)
|
||||
|
||||
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
@@ -362,41 +360,26 @@ private extension AppViewController
|
||||
{
|
||||
func update()
|
||||
{
|
||||
var buttonAction: AppBannerView.AppAction?
|
||||
|
||||
if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||
{
|
||||
// Explicitly set button action to .update if there is an update available, even if it's not supported.
|
||||
buttonAction = .update
|
||||
}
|
||||
|
||||
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
|
||||
{
|
||||
button.tintColor = self.app.tintColor
|
||||
button.isIndicatingActivity = false
|
||||
|
||||
if let installedApp = self.app.installedApp
|
||||
{
|
||||
if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion)
|
||||
{
|
||||
button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
|
||||
}
|
||||
else
|
||||
{
|
||||
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
||||
}
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: self.app)
|
||||
button.progress = progress
|
||||
}
|
||||
self.bannerView.configure(for: self.app, action: buttonAction)
|
||||
|
||||
if let versionDate = self.app.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
self.bannerView.button.countdownDate = versionDate
|
||||
self.navigationBarDownloadButton.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
self.bannerView.button.countdownDate = nil
|
||||
self.navigationBarDownloadButton.countdownDate = nil
|
||||
}
|
||||
let title = self.bannerView.button.title(for: .normal)
|
||||
self.navigationBarDownloadButton.setTitle(title, for: .normal)
|
||||
self.navigationBarDownloadButton.progress = self.bannerView.button.progress
|
||||
self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
|
||||
|
||||
let barButtonItem = self.navigationItem.rightBarButtonItem
|
||||
self.navigationItem.rightBarButtonItem = nil
|
||||
@@ -523,9 +506,9 @@ extension AppViewController
|
||||
{
|
||||
if let installedApp = self.app.installedApp
|
||||
{
|
||||
if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion)
|
||||
if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||
{
|
||||
self.updateApp(installedApp)
|
||||
self.updateApp(installedApp, to: latestVersion)
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -576,7 +559,7 @@ extension AppViewController
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
|
||||
func updateApp(_ installedApp: InstalledApp)
|
||||
func updateApp(_ installedApp: InstalledApp, to version: AppVersion)
|
||||
{
|
||||
let previousProgress = AppManager.shared.installationProgress(for: installedApp)
|
||||
guard previousProgress == nil else {
|
||||
@@ -585,7 +568,7 @@ extension AppViewController
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.update(installedApp, presentingViewController: self) { (result) in
|
||||
AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
|
||||
@@ -92,7 +92,6 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
self.fetchSource()
|
||||
self.updateDataSource()
|
||||
|
||||
self.update()
|
||||
}
|
||||
@@ -109,7 +108,8 @@ private extension BrowseViewController
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
let predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
let predicate = StoreApp.visibleAppsPredicate
|
||||
|
||||
if let source = self.source
|
||||
{
|
||||
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
|
||||
@@ -136,40 +136,8 @@ private extension BrowseViewController
|
||||
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||
cell.bannerView.button.activityIndicatorView.color = .white
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
let tintColor = app.tintColor ?? .altPrimary
|
||||
cell.tintColor = tintColor
|
||||
|
||||
if app.installedApp == nil
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = app.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
cell.bannerView.button.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = nil
|
||||
cell.bannerView.button.progress = nil
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
||||
let iconURL = storeApp.iconURL
|
||||
@@ -202,18 +170,6 @@ private extension BrowseViewController
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func updateDataSource()
|
||||
{
|
||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
||||
{
|
||||
self.dataSource.predicate = nil
|
||||
}
|
||||
else
|
||||
{
|
||||
self.dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta))
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSource()
|
||||
{
|
||||
self.loadingState = .loading
|
||||
@@ -317,7 +273,7 @@ private extension BrowseViewController
|
||||
|
||||
let app = self.dataSource.item(at: indexPath)
|
||||
|
||||
if let installedApp = app.installedApp
|
||||
if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
@@ -335,7 +291,21 @@ private extension BrowseViewController
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
|
||||
if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
else
|
||||
{
|
||||
AppManager.shared.install(app, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func finish(_ result: Result<InstalledApp, Error>)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
@@ -348,11 +318,18 @@ private extension BrowseViewController
|
||||
case .success: print("Installed app:", app.bundleIdentifier)
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
|
||||
{
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
else
|
||||
{
|
||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp)
|
||||
|
||||
@@ -18,6 +18,14 @@ extension AppBannerView
|
||||
case app
|
||||
case source
|
||||
}
|
||||
|
||||
enum AppAction
|
||||
{
|
||||
case install
|
||||
case open
|
||||
case update
|
||||
case custom(String)
|
||||
}
|
||||
}
|
||||
|
||||
class AppBannerView: RSTNibView
|
||||
@@ -111,7 +119,7 @@ class AppBannerView: RSTNibView
|
||||
|
||||
extension AppBannerView
|
||||
{
|
||||
func configure(for app: AppProtocol)
|
||||
func configure(for app: AppProtocol, action: AppAction? = nil)
|
||||
{
|
||||
struct AppValues
|
||||
{
|
||||
@@ -150,6 +158,136 @@ extension AppBannerView
|
||||
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||
self.accessibilityLabel = values.name
|
||||
}
|
||||
|
||||
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
||||
{
|
||||
// Always show button label for Patreon apps.
|
||||
self.buttonLabel.isHidden = false
|
||||
self.buttonLabel.text = storeApp.isPledged ? NSLocalizedString("Pledged", comment: "") : NSLocalizedString("Join Patreon", comment: "")
|
||||
}
|
||||
else
|
||||
{
|
||||
self.buttonLabel.isHidden = true
|
||||
}
|
||||
|
||||
let buttonAction: AppAction
|
||||
|
||||
if let action
|
||||
{
|
||||
buttonAction = action
|
||||
}
|
||||
else if let storeApp = app.storeApp
|
||||
{
|
||||
if let installedApp = storeApp.installedApp
|
||||
{
|
||||
// App is installed
|
||||
|
||||
if installedApp.isUpdateAvailable && (!storeApp.isPledgeRequired || storeApp.isPledged)
|
||||
{
|
||||
buttonAction = .update
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonAction = .open
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// App is not installed
|
||||
buttonAction = .install
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// App is not from a source, fall back to .open
|
||||
buttonAction = .open
|
||||
}
|
||||
|
||||
switch buttonAction
|
||||
{
|
||||
case .open:
|
||||
let buttonTitle = NSLocalizedString("Open", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
|
||||
self.button.countdownDate = nil
|
||||
|
||||
case .update:
|
||||
let buttonTitle = NSLocalizedString("Update", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
|
||||
self.button.countdownDate = nil
|
||||
|
||||
case .custom(let buttonTitle):
|
||||
self.button.setTitle(buttonTitle, for: .normal)
|
||||
self.button.accessibilityLabel = buttonTitle
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
|
||||
self.button.countdownDate = nil
|
||||
|
||||
case .install:
|
||||
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
||||
{
|
||||
// Pledge required
|
||||
|
||||
if storeApp.isPledged
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Install", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
}
|
||||
else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, #available(iOS 15, *)
|
||||
{
|
||||
let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2)))
|
||||
|
||||
let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price)
|
||||
self.button.setTitle(buttonTitle, for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price)
|
||||
self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price)
|
||||
}
|
||||
else
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Pledge", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = buttonTitle
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Free app
|
||||
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
}
|
||||
|
||||
if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
self.button.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
self.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure PillButton is correct size before assigning progress.
|
||||
self.layoutIfNeeded()
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0
|
||||
{
|
||||
self.button.progress = progress
|
||||
}
|
||||
else
|
||||
{
|
||||
self.button.progress = nil
|
||||
}
|
||||
}
|
||||
|
||||
func configure(for source: Source)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
@@ -78,13 +78,13 @@
|
||||
</subviews>
|
||||
</stackView>
|
||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
|
||||
<rect key="frame" x="0.0" y="21.5" width="184" height="16"/>
|
||||
<rect key="frame" x="0.0" y="21.5" width="62" height="16"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="184" height="16"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="184" height="16"/>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@@ -103,17 +103,8 @@
|
||||
</visualEffectView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3ox-Q2-Rnd" userLabel="Button Stack View">
|
||||
<rect key="frame" x="282" y="28.5" width="77" height="31"/>
|
||||
<subviews>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||
<rect key="frame" x="0.0" y="0.0" width="77" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="77" height="31"/>
|
||||
<rect key="frame" x="282" y="28.5" width="77" height="31"/>
|
||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
|
||||
@@ -123,16 +114,22 @@
|
||||
<state key="normal" title="FREE"/>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
||||
</stackView>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||
<rect key="frame" x="307" y="12.5" width="27" height="12"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
|
||||
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
|
||||
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
|
||||
<constraint firstItem="Yd9-jw-faD" firstAttribute="centerX" secondItem="tVx-3G-dcu" secondAttribute="centerX" id="acx-pf-8hH"/>
|
||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
|
||||
<constraint firstItem="tVx-3G-dcu" firstAttribute="top" secondItem="Yd9-jw-faD" secondAttribute="bottom" constant="4" id="hTD-wh-KV8"/>
|
||||
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
|
||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/>
|
||||
|
||||
@@ -289,6 +289,10 @@ extension AppCardCollectionViewCell
|
||||
{
|
||||
self.screenshots = storeApp.preferredScreenshots()
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
self.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
self.bannerView.tintColor = storeApp.tintColor
|
||||
self.bannerView.configure(for: storeApp)
|
||||
|
||||
|
||||
14
AltStore/Extensions/UTType+AltStore.swift
Normal file
14
AltStore/Extensions/UTType+AltStore.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// UTType+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 11/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension UTType
|
||||
{
|
||||
static let ipa = UTType(importedAs: "com.apple.itunes.ipa")
|
||||
}
|
||||
@@ -181,6 +181,8 @@
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<string>ipa</string>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-ios-app</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -557,9 +557,9 @@ extension AppManager
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(_ installedApp: InstalledApp, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
||||
func update(_ installedApp: InstalledApp, to version: AppVersion? = nil, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
||||
{
|
||||
guard let appVersion = installedApp.storeApp?.latestSupportedVersion else {
|
||||
guard let appVersion = version ?? installedApp.storeApp?.latestSupportedVersion else {
|
||||
completionHandler(.failure(OperationError.appNotFound(name: installedApp.name)))
|
||||
return Progress.discreteProgress(totalUnitCount: 1)
|
||||
}
|
||||
@@ -1246,7 +1246,7 @@ private extension AppManager
|
||||
let patchAppURL = URL(string: patchAppLink)
|
||||
else { throw OperationError.invalidApp }
|
||||
|
||||
let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL)
|
||||
let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL, storeApp: nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let storyboard = UIStoryboard(name: "PatchApp", bundle: nil)
|
||||
@@ -1358,8 +1358,22 @@ private extension AppManager
|
||||
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
|
||||
installOperation.addDependency(sendAppOperation)
|
||||
|
||||
let operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation]
|
||||
var operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation]
|
||||
group.add(operations)
|
||||
|
||||
if let storeApp = downloadingApp.storeApp, storeApp.isPledgeRequired
|
||||
{
|
||||
// Patreon apps may require authenticating with WebViewController,
|
||||
// so make sure to run DownloadAppOperation serially.
|
||||
self.run([downloadOperation], context: group.context, requiresSerialQueue: true)
|
||||
|
||||
if let index = operations.firstIndex(of: downloadOperation)
|
||||
{
|
||||
// Remove downloadOperation from operations to prevent running it twice.
|
||||
operations.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
self.run(operations, context: group.context)
|
||||
|
||||
return progress
|
||||
|
||||
@@ -112,11 +112,13 @@ class MyAppsViewController: UICollectionViewController, PeekPopPreviewing
|
||||
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
override func viewIsAppearing(_ animated: Bool)
|
||||
{
|
||||
super.viewWillAppear(animated)
|
||||
super.viewIsAppearing(animated)
|
||||
|
||||
// Ensure the button for each app reflects correct Patreon status.
|
||||
self.collectionView.reloadData()
|
||||
|
||||
self.updateDataSource()
|
||||
self.update()
|
||||
|
||||
self.fetchAppIDs()
|
||||
@@ -227,7 +229,8 @@ private extension MyAppsViewController
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.configure(for: app)
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.configure(for: app, action: .update)
|
||||
|
||||
let versionDate = Date().relativeDateString(since: latestSupportedVersion.date)
|
||||
cell.bannerView.subtitleLabel.text = versionDate
|
||||
@@ -245,7 +248,6 @@ private extension MyAppsViewController
|
||||
|
||||
cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestSupportedVersion.localizedVersion, versionDate)
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name)
|
||||
|
||||
@@ -260,9 +262,6 @@ private extension MyAppsViewController
|
||||
|
||||
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
cell.setNeedsLayout()
|
||||
}
|
||||
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in
|
||||
@@ -330,17 +329,6 @@ private extension MyAppsViewController
|
||||
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.33, y: 0.33)
|
||||
}
|
||||
|
||||
cell.bannerView.configure(for: installedApp)
|
||||
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
|
||||
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
|
||||
|
||||
let currentDate = Date()
|
||||
|
||||
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
|
||||
@@ -355,9 +343,30 @@ private extension MyAppsViewController
|
||||
numberOfDaysText = String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
|
||||
}
|
||||
|
||||
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.configure(for: installedApp, action: .custom(numberOfDaysText.uppercased()))
|
||||
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
||||
|
||||
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
|
||||
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
|
||||
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), installedApp.name)
|
||||
|
||||
if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged
|
||||
{
|
||||
cell.bannerView.button.isEnabled = false
|
||||
cell.bannerView.button.alpha = 0.5
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.isEnabled = true
|
||||
cell.bannerView.button.alpha = 1.0
|
||||
}
|
||||
|
||||
cell.bannerView.accessibilityLabel? += ". " + String(format: NSLocalizedString("Expires in %@", comment: ""), numberOfDaysText)
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
@@ -430,15 +439,25 @@ private extension MyAppsViewController
|
||||
cell.deactivateBadge?.alpha = 0.0
|
||||
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5)
|
||||
|
||||
cell.bannerView.configure(for: installedApp)
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.configure(for: installedApp, action: .custom(NSLocalizedString("ACTIVATE", comment: "")))
|
||||
|
||||
cell.bannerView.button.tintColor = tintColor
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("ACTIVATE", comment: ""), for: .normal)
|
||||
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
|
||||
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name)
|
||||
|
||||
if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged
|
||||
{
|
||||
cell.bannerView.button.isEnabled = false
|
||||
cell.bannerView.button.alpha = 0.5
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.isEnabled = true
|
||||
cell.bannerView.button.alpha = 1.0
|
||||
}
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
@@ -475,33 +494,6 @@ private extension MyAppsViewController
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func updateDataSource()
|
||||
{
|
||||
do
|
||||
{
|
||||
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil
|
||||
{
|
||||
try self.updatesDataSource.fetchedResultsController.performFetch()
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("[ALTLog] Failed to fetch updates:", error)
|
||||
}
|
||||
|
||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
||||
{
|
||||
self.dataSource.predicate = nil
|
||||
}
|
||||
else
|
||||
{
|
||||
self.dataSource.predicate = NSPredicate(format: "%K == nil OR %K == NO OR %K == %@",
|
||||
#keyPath(InstalledApp.storeApp),
|
||||
#keyPath(InstalledApp.storeApp.isBeta),
|
||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension MyAppsViewController
|
||||
@@ -710,11 +702,30 @@ private extension MyAppsViewController
|
||||
|
||||
@IBAction func refreshAllApps(_ sender: UIBarButtonItem)
|
||||
{
|
||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
|
||||
guard !installedApps.isEmpty else {
|
||||
let error: Error
|
||||
|
||||
if let altstoreApp = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext),
|
||||
let storeApp = altstoreApp.storeApp, storeApp.isPledgeRequired && !storeApp.isPledged
|
||||
{
|
||||
// Assume the reason there are no apps is because we are no longer pledged to AltStore beta.
|
||||
error = OperationError(.pledgeInactive(appName: altstoreApp.name))
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, fall back to generic noInstalledApps.
|
||||
error = RefreshError(.noInstalledApps)
|
||||
}
|
||||
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
return
|
||||
}
|
||||
|
||||
self.isRefreshingAllApps = true
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
|
||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
|
||||
|
||||
self.refresh(installedApps) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
self.isRefreshingAllApps = false
|
||||
@@ -1704,7 +1715,7 @@ extension MyAppsViewController
|
||||
|
||||
extension MyAppsViewController
|
||||
{
|
||||
private func actions(for installedApp: InstalledApp) -> [UIMenuElement]
|
||||
private func contextMenu(for installedApp: InstalledApp) -> UIMenu
|
||||
{
|
||||
var actions = [UIMenuElement]()
|
||||
|
||||
@@ -1762,14 +1773,16 @@ extension MyAppsViewController
|
||||
|
||||
let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions)
|
||||
|
||||
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else {
|
||||
if installedApp.bundleIdentifier == StoreApp.altstoreAppID
|
||||
{
|
||||
#if BETA
|
||||
return [refreshAction, changeIconMenu]
|
||||
actions = [refreshAction, changeIconMenu]
|
||||
#else
|
||||
return [refreshAction]
|
||||
actions = [refreshAction]
|
||||
#endif
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
if installedApp.isActive
|
||||
{
|
||||
actions.append(openMenu)
|
||||
@@ -1857,8 +1870,43 @@ extension MyAppsViewController
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
return actions
|
||||
var title: String?
|
||||
|
||||
if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged
|
||||
{
|
||||
let error = OperationError.pledgeInactive(appName: installedApp.name)
|
||||
title = error.localizedDescription
|
||||
|
||||
let allowedActions: Set<UIMenuElement> = [
|
||||
openMenu,
|
||||
deactivateAction,
|
||||
removeAction,
|
||||
backupAction,
|
||||
exportBackupAction
|
||||
]
|
||||
|
||||
for action in actions where !allowedActions.contains(action)
|
||||
{
|
||||
// Disable options for Patreon apps that we are no longer pledged to.
|
||||
|
||||
if let action = action as? UIAction
|
||||
{
|
||||
action.attributes = .disabled
|
||||
}
|
||||
else if let menu = action as? UIMenu
|
||||
{
|
||||
for case let action as UIAction in menu.children
|
||||
{
|
||||
action.attributes = .disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let menu = UIMenu(title: title ?? "", children: actions)
|
||||
return menu
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
|
||||
@@ -1871,9 +1919,7 @@ extension MyAppsViewController
|
||||
let installedApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in
|
||||
let actions = self.actions(for: installedApp)
|
||||
|
||||
let menu = UIMenu(title: "", children: actions)
|
||||
let menu = self.contextMenu(for: installedApp)
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ extension UpdateCollectionViewCell
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
self.bannerView.backgroundEffectView.isHidden = true
|
||||
self.bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
|
||||
|
||||
self.blurView.layer.cornerRadius = 20
|
||||
self.blurView.layer.masksToBounds = true
|
||||
|
||||
@@ -341,7 +341,7 @@ private extension NewsViewController
|
||||
let app = self.dataSource.item(at: indexPath)
|
||||
guard let storeApp = app.storeApp else { return }
|
||||
|
||||
if let installedApp = app.storeApp?.installedApp
|
||||
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
@@ -359,7 +359,21 @@ private extension NewsViewController
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
|
||||
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
else
|
||||
{
|
||||
AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||
}
|
||||
|
||||
func finish(_ result: Result<InstalledApp, Error>)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
@@ -377,10 +391,6 @@ private extension NewsViewController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp)
|
||||
@@ -426,42 +436,13 @@ extension NewsViewController
|
||||
footerView.layoutMargins.left = self.view.layoutMargins.left
|
||||
footerView.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
footerView.bannerView.button.isIndicatingActivity = false
|
||||
footerView.bannerView.configure(for: storeApp)
|
||||
|
||||
footerView.bannerView.tintColor = storeApp.tintColor
|
||||
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:)))
|
||||
|
||||
footerView.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
if storeApp.installedApp == nil
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
|
||||
footerView.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: storeApp)
|
||||
footerView.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
footerView.bannerView.button.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
footerView.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name)
|
||||
footerView.bannerView.button.accessibilityValue = nil
|
||||
footerView.bannerView.button.progress = nil
|
||||
footerView.bannerView.button.countdownDate = nil
|
||||
}
|
||||
|
||||
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
|
||||
|
||||
return footerView
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Roxas
|
||||
import WebKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
import AltStoreCore
|
||||
import AltSign
|
||||
import Roxas
|
||||
|
||||
@objc(DownloadAppOperation)
|
||||
class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
{
|
||||
let app: AppProtocol
|
||||
@Managed
|
||||
private(set) var app: AppProtocol
|
||||
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
private let appName: String
|
||||
@@ -25,6 +29,8 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
private let session = URLSession(configuration: .default)
|
||||
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
private var downloadPatreonAppContinuation: CheckedContinuation<URL, Error>?
|
||||
|
||||
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
|
||||
{
|
||||
self.app = app
|
||||
@@ -55,22 +61,36 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
// Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors.
|
||||
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
|
||||
|
||||
guard let storeApp = self.app as? StoreApp else {
|
||||
// Only StoreApp allows falling back to previous versions.
|
||||
// AppVersion can only install itself, and ALTApplication doesn't have previous versions.
|
||||
return self.download(self.app)
|
||||
}
|
||||
|
||||
// Verify storeApp
|
||||
storeApp.managedObjectContext?.perform {
|
||||
self.$app.perform { app in
|
||||
do
|
||||
{
|
||||
let latestVersion = try self.verify(storeApp)
|
||||
self.download(latestVersion)
|
||||
var appVersion: AppVersion?
|
||||
|
||||
if let version = app as? AppVersion
|
||||
{
|
||||
appVersion = version
|
||||
}
|
||||
else if let storeApp = app as? StoreApp
|
||||
{
|
||||
guard let latestVersion = storeApp.latestAvailableVersion else {
|
||||
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
|
||||
throw OperationError.unknown(failureReason: failureReason)
|
||||
}
|
||||
|
||||
// Attempt to download latest _available_ version, and fall back to older versions if necessary.
|
||||
appVersion = latestVersion
|
||||
}
|
||||
|
||||
if let appVersion
|
||||
{
|
||||
try self.verify(appVersion)
|
||||
}
|
||||
|
||||
self.download(appVersion ?? app)
|
||||
}
|
||||
catch let error as VerificationError where error.code == .iOSVersionNotSupported
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController, let latestSupportedVersion = storeApp.latestSupportedVersion
|
||||
guard let presentingViewController = self.context.presentingViewController, let storeApp = app.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion
|
||||
else { return self.finish(.failure(error)) }
|
||||
|
||||
if let installedApp = storeApp.installedApp
|
||||
@@ -117,23 +137,16 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
|
||||
private extension DownloadAppOperation
|
||||
{
|
||||
func verify(_ storeApp: StoreApp) throws -> AppVersion
|
||||
func verify(_ version: AppVersion) throws
|
||||
{
|
||||
guard let version = storeApp.latestAvailableVersion else {
|
||||
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
|
||||
throw OperationError.unknown(failureReason: failureReason)
|
||||
}
|
||||
|
||||
if let minOSVersion = version.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion)
|
||||
{
|
||||
throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: minOSVersion)
|
||||
throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: minOSVersion)
|
||||
}
|
||||
else if let maxOSVersion = version.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion
|
||||
{
|
||||
throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: maxOSVersion)
|
||||
throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: maxOSVersion)
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
func download(@Managed _ app: AppProtocol)
|
||||
@@ -194,11 +207,43 @@ private extension DownloadAppOperation
|
||||
|
||||
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
|
||||
{
|
||||
func finishOperation(_ result: Result<URL, Error>)
|
||||
{
|
||||
Task<Void, Never>.detached(priority: .userInitiated) {
|
||||
do
|
||||
{
|
||||
let fileURL = try result.get()
|
||||
let fileURL: URL
|
||||
|
||||
if sourceURL.isFileURL
|
||||
{
|
||||
fileURL = sourceURL
|
||||
self.progress.completedUnitCount += 3
|
||||
}
|
||||
else if let (isPledged, isPledgeRequired) = await self.context.$appVersion.perform({ $0?.app.map { ($0.isPledged, $0.isPledgeRequired) } }), isPledgeRequired && !isPledged
|
||||
{
|
||||
// Not pledged, so just show Patreon page.
|
||||
guard let presentingViewController = self.context.presentingViewController,
|
||||
let patreonURL = await self.context.$appVersion.perform({ $0?.app?.source?.patreonURL })
|
||||
else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
// Intercept downloads just in case they are in fact pledged.
|
||||
fileURL = try await self.downloadFromPatreon(patreonURL, presentingViewController: presentingViewController)
|
||||
}
|
||||
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
|
||||
{
|
||||
// Patreon app
|
||||
fileURL = try await self.downloadPatreonApp(from: sourceURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular app
|
||||
fileURL = try await self.downloadFile(from: sourceURL)
|
||||
}
|
||||
|
||||
defer {
|
||||
if !sourceURL.isFileURL
|
||||
{
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) }
|
||||
@@ -235,31 +280,26 @@ private extension DownloadAppOperation
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
if sourceURL.isFileURL
|
||||
{
|
||||
finishOperation(.success(sourceURL))
|
||||
|
||||
self.progress.completedUnitCount += 3
|
||||
}
|
||||
else
|
||||
|
||||
func downloadFile(from downloadURL: URL) async throws -> URL
|
||||
{
|
||||
let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
|
||||
do
|
||||
{
|
||||
if let response = response as? HTTPURLResponse
|
||||
{
|
||||
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) }
|
||||
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) }
|
||||
}
|
||||
|
||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
||||
finishOperation(.success(fileURL))
|
||||
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
continuation.resume(returning: fileURL)
|
||||
}
|
||||
catch
|
||||
{
|
||||
finishOperation(.failure(error))
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
|
||||
@@ -267,6 +307,162 @@ private extension DownloadAppOperation
|
||||
downloadTask.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
|
||||
{
|
||||
guard !UserDefaults.shared.skipPatreonDownloads else {
|
||||
// Skip all hacks, take user straight to Patreon post.
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// User is pledged to this app, attempt to download.
|
||||
|
||||
let fileURL = try await self.downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
// Attempt to sign-in again in case our Patreon session has expired.
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
catch
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// Success, so try to download once more now that we're definitely authenticated.
|
||||
|
||||
let fileURL = try await self.downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
{
|
||||
// We know authentication succeeded, so failure must mean user isn't patron/on the correct tier,
|
||||
// or that our hacky workaround for downloading Patreon attachments has failed.
|
||||
// Either way, taking them directly to the post serves as a decent fallback.
|
||||
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFromPatreonPost() async throws -> URL
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false),
|
||||
let postItem = components.queryItems?.first(where: { $0.name == "h" }),
|
||||
let postID = postItem.value,
|
||||
let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID)
|
||||
{
|
||||
downloadURL = patreonPostURL
|
||||
}
|
||||
else
|
||||
{
|
||||
downloadURL = patreonURL
|
||||
}
|
||||
|
||||
return try await self.downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL
|
||||
{
|
||||
let webViewController = WebViewController(url: patreonURL)
|
||||
webViewController.delegate = self
|
||||
webViewController.webView.navigationDelegate = self
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
do
|
||||
{
|
||||
defer {
|
||||
navigationController.dismiss(animated: true)
|
||||
}
|
||||
|
||||
downloadURL = try await withCheckedThrowingContinuation { continuation in
|
||||
self.downloadPatreonAppContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
let fileURL = try await self.downloadFile(from: downloadURL)
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadAppOperation: WebViewControllerDelegate
|
||||
{
|
||||
func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
{
|
||||
guard let continuation = self.downloadPatreonAppContinuation else { return }
|
||||
self.downloadPatreonAppContinuation = nil
|
||||
|
||||
continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadAppOperation: WKNavigationDelegate
|
||||
{
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy
|
||||
{
|
||||
guard #available(iOS 14.5, *), navigationAction.shouldPerformDownload else { return .allow }
|
||||
|
||||
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
|
||||
self.downloadPatreonAppContinuation = nil
|
||||
|
||||
if let downloadURL = navigationAction.request.url
|
||||
{
|
||||
continuation.resume(returning: downloadURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
continuation.resume(throwing: URLError(.badURL))
|
||||
}
|
||||
|
||||
return .cancel
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy
|
||||
{
|
||||
// Called for Patreon attachments
|
||||
|
||||
guard !navigationResponse.canShowMIMEType else { return .allow }
|
||||
|
||||
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
|
||||
self.downloadPatreonAppContinuation = nil
|
||||
|
||||
guard let response = navigationResponse.response as? HTTPURLResponse, let responseURL = response.url,
|
||||
let mimeType = response.mimeType, let type = UTType(mimeType: mimeType),
|
||||
type.conforms(to: .ipa) || type.conforms(to: .zip) || type.conforms(to: .application)
|
||||
else {
|
||||
continuation.resume(throwing: OperationError.invalidApp)
|
||||
return .cancel
|
||||
}
|
||||
|
||||
continuation.resume(returning: responseURL)
|
||||
|
||||
return .cancel
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadAppOperation
|
||||
|
||||
@@ -36,6 +36,10 @@ extension OperationError
|
||||
case serverNotFound = 1200
|
||||
case connectionFailed = 1201
|
||||
case connectionDropped = 1202
|
||||
|
||||
/* Pledges */
|
||||
case pledgeRequired = 1401
|
||||
case pledgeInactive = 1402
|
||||
}
|
||||
|
||||
static var cancelled: CancellationError { CancellationError() }
|
||||
@@ -67,6 +71,14 @@ extension OperationError
|
||||
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line)
|
||||
}
|
||||
|
||||
static func pledgeRequired(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||
OperationError(code: .pledgeRequired, appName: appName, sourceFile: file, sourceLine: line)
|
||||
}
|
||||
|
||||
static func pledgeInactive(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||
OperationError(code: .pledgeInactive, appName: appName, sourceFile: file, sourceLine: line)
|
||||
}
|
||||
}
|
||||
|
||||
struct OperationError: ALTLocalizedError
|
||||
@@ -132,6 +144,14 @@ struct OperationError: ALTLocalizedError
|
||||
case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "")
|
||||
case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "")
|
||||
case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "")
|
||||
|
||||
case .pledgeRequired:
|
||||
let appName = self.appName ?? NSLocalizedString("This app", comment: "")
|
||||
return String(format: NSLocalizedString("%@ requires an active pledge in order to be installed.", comment: ""), appName)
|
||||
|
||||
case .pledgeInactive:
|
||||
let appName = self.appName ?? NSLocalizedString("this app", comment: "")
|
||||
return String(format: NSLocalizedString("Your pledge is no longer active. Please renew it to continue using %@ normally.", comment: ""), appName)
|
||||
}
|
||||
}
|
||||
private var _failureReason: String?
|
||||
|
||||
@@ -153,7 +153,13 @@ class FetchSourceOperation: ResultOperation<Source>
|
||||
|
||||
let identifier = source.identifier
|
||||
|
||||
if identifier == Source.altStoreIdentifier, let skipPatreonDownloads = source.userInfo?[.skipPatreonDownloads]
|
||||
{
|
||||
UserDefaults.shared.skipPatreonDownloads = (skipPatreonDownloads == "true")
|
||||
}
|
||||
|
||||
try self.verify(source, response: response)
|
||||
try self.verifyPledges(for: source, in: childContext)
|
||||
|
||||
try childContext.save()
|
||||
|
||||
@@ -223,6 +229,63 @@ private extension FetchSourceOperation
|
||||
}
|
||||
}
|
||||
|
||||
func verifyPledges(for source: Source, in context: NSManagedObjectContext) throws
|
||||
{
|
||||
guard let patreonURL = source.patreonURL, let patreonAccount = DatabaseManager.shared.patreonAccount(in: context) else { return }
|
||||
|
||||
let normalizedPatreonURL = try patreonURL.normalized()
|
||||
|
||||
guard let pledge = patreonAccount.pledges.first(where: { pledge in
|
||||
do
|
||||
{
|
||||
let normalizedCampaignURL = try pledge.campaignURL.normalized()
|
||||
return normalizedCampaignURL == normalizedPatreonURL
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.main.error("Failed to normalize Patreon URL \(pledge.campaignURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}) else { return }
|
||||
|
||||
// User is pledged to this source's Patreon, so check which apps they're pledged to.
|
||||
|
||||
// We only assign `isPledged = true` because false is already the default,
|
||||
// and only one check needs to be true for isPledged to be true.
|
||||
|
||||
for app in source.apps where app.isPledgeRequired
|
||||
{
|
||||
if let requiredAppPledge = app.pledgeAmount
|
||||
{
|
||||
if pledge.amount >= requiredAppPledge
|
||||
{
|
||||
app.isPledged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if let tierIDs = app._tierIDs
|
||||
{
|
||||
let tier = pledge.tiers.first { tierIDs.contains($0.identifier) }
|
||||
if tier != nil
|
||||
{
|
||||
app.isPledged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if let rewardID = app._rewardID
|
||||
{
|
||||
let reward = pledge.rewards.first { $0.identifier == rewardID }
|
||||
if reward != nil
|
||||
{
|
||||
app.isPledged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws
|
||||
{
|
||||
guard let blockedSources = UserDefaults.shared.blockedSources else { return }
|
||||
|
||||
@@ -110,7 +110,7 @@ class PatchAppOperation: ResultOperation<Void>
|
||||
.flatMap { self.patch(resignedApp, withBinaryAt: $0) }
|
||||
.tryMap { try FileManager.default.zipAppBundle(at: $0) }
|
||||
.tryMap { (fileURL) in
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL)
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
|
||||
|
||||
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
|
||||
|
||||
@@ -44,7 +44,7 @@ class SendAppOperation: ResultOperation<ServerConnection>
|
||||
Logger.sideload.notice("Sending app \(self.context.bundleIdentifier, privacy: .public) to AltServer \(server.localizedName ?? "nil", privacy: .public)...")
|
||||
|
||||
// self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa.
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL)
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
|
||||
let fileURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
|
||||
// Connect to server.
|
||||
|
||||
@@ -138,7 +138,7 @@ private extension PatreonViewController
|
||||
headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered)
|
||||
headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal)
|
||||
|
||||
if account.isPatron
|
||||
if account.isAltStorePatron
|
||||
{
|
||||
headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal)
|
||||
|
||||
@@ -191,19 +191,35 @@ private extension PatreonViewController
|
||||
|
||||
@IBAction func authenticate(_ sender: UIBarButtonItem)
|
||||
{
|
||||
PatreonAPI.shared.authenticate { (result) in
|
||||
PatreonAPI.shared.authenticate(presentingViewController: self) { (result) in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
// Update sources to show any Patreon-only apps.
|
||||
AppManager.shared.fetchSources { result in
|
||||
do
|
||||
{
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.main.error("Failed to update sources after authenticating Patreon account. \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
catch ASWebAuthenticationSessionError.canceledLogin
|
||||
}
|
||||
catch is CancellationError
|
||||
{
|
||||
// Ignore
|
||||
// Clear in-app browser cache in case they are signed into wrong account.
|
||||
Task<Void, Never>.detached {
|
||||
await PatreonAPI.shared.deleteAuthCookies()
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -215,7 +215,7 @@ private extension SourceDetailContentViewController
|
||||
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<StoreApp, UIImage>(items: limitedFeaturedApps)
|
||||
dataSource.cellIdentifierHandler = { _ in "AppCell" }
|
||||
dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta)) // Never show beta apps (at least until we support betas for other sources).
|
||||
dataSource.predicate = StoreApp.visibleAppsPredicate
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, storeApp, indexPath) in
|
||||
let cell = cell as! AppBannerCollectionViewCell
|
||||
cell.tintColor = storeApp.tintColor
|
||||
@@ -225,43 +225,13 @@ private extension SourceDetailContentViewController
|
||||
cell.contentView.layoutMargins = .zero
|
||||
cell.contentView.backgroundColor = .altBackground
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.configure(for: storeApp)
|
||||
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
cell.bannerView.buttonLabel.isHidden = true
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.button.tintColor = storeApp.tintColor
|
||||
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
|
||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.addSourceThenDownloadApp(_:)), for: .primaryActionTriggered)
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: storeApp)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
cell.bannerView.button.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: storeApp), progress.fractionCompleted < 1.0
|
||||
{
|
||||
cell.bannerView.button.progress = progress
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.progress = nil
|
||||
}
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
||||
return RSTAsyncBlockOperation { (operation) in
|
||||
@@ -404,16 +374,30 @@ extension SourceDetailContentViewController
|
||||
|
||||
private extension SourceDetailContentViewController
|
||||
{
|
||||
@objc func addSourceThenDownloadApp(_ sender: UIButton)
|
||||
@objc func performAppAction(_ sender: PillButton)
|
||||
{
|
||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
sender.isIndicatingActivity = true
|
||||
|
||||
let storeApp = self.dataSource.item(at: indexPath) as! StoreApp
|
||||
|
||||
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
else
|
||||
{
|
||||
sender.isIndicatingActivity = true
|
||||
|
||||
Task<Void, Never> {
|
||||
await self.addSourceThenDownloadApp(storeApp)
|
||||
sender.isIndicatingActivity = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addSourceThenDownloadApp(_ storeApp: StoreApp) async
|
||||
{
|
||||
do
|
||||
{
|
||||
let isAdded = try await self.source.isAdded
|
||||
@@ -427,7 +411,7 @@ private extension SourceDetailContentViewController
|
||||
{
|
||||
try await self.downloadApp(storeApp)
|
||||
}
|
||||
catch OperationError.cancelled {}
|
||||
catch is CancellationError {}
|
||||
catch
|
||||
{
|
||||
let toastView = ToastView(error: error)
|
||||
@@ -441,18 +425,27 @@ private extension SourceDetailContentViewController
|
||||
await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription)
|
||||
}
|
||||
|
||||
sender.isIndicatingActivity = false
|
||||
self.collectionView.reloadSections([Section.featuredApps.rawValue])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func downloadApp(_ storeApp: StoreApp) async throws
|
||||
{
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self) { result in
|
||||
continuation.resume(with: result.map { _ in () })
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AppManager.shared.install(storeApp, presentingViewController: self) { result in
|
||||
continuation.resume(with: result.map { _ in })
|
||||
continuation.resume(with: result.map { _ in () })
|
||||
}
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else {
|
||||
self.collectionView.reloadSections([Section.featuredApps.rawValue])
|
||||
return
|
||||
@@ -462,6 +455,12 @@ private extension SourceDetailContentViewController
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp)
|
||||
{
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension SourceDetailContentViewController: ScrollableContentViewController
|
||||
|
||||
@@ -204,15 +204,7 @@ private extension SourcesViewController
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
let numberOfApps: Int
|
||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
||||
{
|
||||
numberOfApps = source.apps.count
|
||||
}
|
||||
else
|
||||
{
|
||||
numberOfApps = source.apps.filter { !$0.isBeta }.count
|
||||
}
|
||||
let numberOfApps = source.apps.filter { StoreApp.visibleAppsPredicate.evaluate(with: $0) }.count
|
||||
|
||||
if let error = source.error
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ FOUNDATION_EXPORT const unsigned char AltStoreCoreVersionString[];
|
||||
|
||||
#import <AltStoreCore/ALTAppPermissions.h>
|
||||
#import <AltStoreCore/ALTSourceUserInfoKey.h>
|
||||
#import <AltStoreCore/ALTPatreonBenefitType.h>
|
||||
#import <AltStoreCore/ALTPatreonBenefitID.h>
|
||||
|
||||
// Shared
|
||||
#import <AltStoreCore/ALTConstants.h>
|
||||
|
||||
356
AltStoreCore/Components/WebViewController.swift
Normal file
356
AltStoreCore/Components/WebViewController.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
//
|
||||
// WebViewController.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/31/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
import Combine
|
||||
|
||||
public protocol WebViewControllerDelegate: NSObject
|
||||
{
|
||||
func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
}
|
||||
|
||||
public class WebViewController: UIViewController
|
||||
{
|
||||
//MARK: Public Properties
|
||||
public weak var delegate: WebViewControllerDelegate?
|
||||
|
||||
// WKWebView used to display webpages
|
||||
public private(set) var webView: WKWebView!
|
||||
|
||||
public private(set) lazy var backButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.backward"), style: .plain, target: self, action: #selector(WebViewController.goBack(_:)))
|
||||
public private(set) lazy var forwardButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.forward"), style: .plain, target: self, action: #selector(WebViewController.goForward(_:)))
|
||||
public private(set) lazy var shareButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(WebViewController.shareLink(_:)))
|
||||
|
||||
public private(set) lazy var reloadButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(WebViewController.refresh(_:)))
|
||||
public private(set) lazy var stopLoadingButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(WebViewController.refresh(_:)))
|
||||
|
||||
public private(set) lazy var doneButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(WebViewController.dismissWebViewController(_:)))
|
||||
|
||||
//MARK: Private Properties
|
||||
private let progressView = UIProgressView()
|
||||
private lazy var refreshButton: UIBarButtonItem = self.reloadButton
|
||||
|
||||
private let initialReqest: URLRequest?
|
||||
private var ignoreUpdateProgress: Bool = false
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
public required init(request: URLRequest?, configuration: WKWebViewConfiguration = WKWebViewConfiguration())
|
||||
{
|
||||
self.initialReqest = request
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.webView = WKWebView(frame: CGRectZero, configuration: configuration)
|
||||
self.webView.allowsBackForwardNavigationGestures = true
|
||||
|
||||
self.progressView.progressViewStyle = .bar
|
||||
self.progressView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.progressView.progress = 0.5
|
||||
self.progressView.alpha = 0.0
|
||||
self.progressView.isHidden = true
|
||||
}
|
||||
|
||||
public convenience init(url: URL?, configuration: WKWebViewConfiguration = WKWebViewConfiguration())
|
||||
{
|
||||
if let url
|
||||
{
|
||||
let request = URLRequest(url: url)
|
||||
self.init(request: request, configuration: configuration)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.init(request: nil, configuration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
//MARK: UIViewController
|
||||
|
||||
public override func loadView()
|
||||
{
|
||||
self.preparePipeline()
|
||||
|
||||
if let request = self.initialReqest
|
||||
{
|
||||
self.webView.load(request)
|
||||
}
|
||||
|
||||
self.view = self.webView
|
||||
}
|
||||
|
||||
public override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.navigationController?.isModalInPresentation = true
|
||||
self.navigationController?.view.tintColor = .altPrimary
|
||||
|
||||
if let navigationBar = self.navigationController?.navigationBar
|
||||
{
|
||||
navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance
|
||||
}
|
||||
|
||||
if let toolbar = self.navigationController?.toolbar, #available(iOS 15, *)
|
||||
{
|
||||
toolbar.scrollEdgeAppearance = toolbar.standardAppearance
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewIsAppearing(_ animated: Bool)
|
||||
{
|
||||
super.viewIsAppearing(animated)
|
||||
|
||||
if self.webView.estimatedProgress < 1.0
|
||||
{
|
||||
self.transitionCoordinator?.animate(alongsideTransition: { context in
|
||||
self.showProgressBar(animated: true)
|
||||
}) { context in
|
||||
if context.isCancelled
|
||||
{
|
||||
self.hideProgressBar(animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.navigationController?.setToolbarHidden(false, animated: false)
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
var shouldHideToolbarItems = true
|
||||
|
||||
if let toolbarItems = self.navigationController?.topViewController?.toolbarItems
|
||||
{
|
||||
if toolbarItems.count > 0
|
||||
{
|
||||
shouldHideToolbarItems = false
|
||||
}
|
||||
}
|
||||
|
||||
if shouldHideToolbarItems
|
||||
{
|
||||
self.navigationController?.setToolbarHidden(true, animated: false)
|
||||
}
|
||||
|
||||
self.transitionCoordinator?.animate(alongsideTransition: { context in
|
||||
self.hideProgressBar(animated: true)
|
||||
}) { (context) in
|
||||
if context.isCancelled && self.webView.estimatedProgress < 1.0
|
||||
{
|
||||
self.showProgressBar(animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func didMove(toParent parent: UIViewController?)
|
||||
{
|
||||
super.didMove(toParent: parent)
|
||||
|
||||
if parent == nil
|
||||
{
|
||||
self.webView.stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
deinit
|
||||
{
|
||||
self.webView.stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private extension WebViewController
|
||||
{
|
||||
func preparePipeline()
|
||||
{
|
||||
self.webView.publisher(for: \.title, options: [.initial, .new])
|
||||
.sink { [weak self] title in
|
||||
self?.title = title
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
|
||||
self.webView.publisher(for: \.estimatedProgress, options: [.new])
|
||||
.sink { [weak self] progress in
|
||||
self?.updateProgress(progress)
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
|
||||
Publishers.Merge3(
|
||||
self.webView.publisher(for: \.isLoading, options: [.new]),
|
||||
self.webView.publisher(for: \.canGoBack, options: [.new]),
|
||||
self.webView.publisher(for: \.canGoForward, options: [.new])
|
||||
)
|
||||
.sink { [weak self] _ in
|
||||
self?.update()
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
func update()
|
||||
{
|
||||
if self.webView.isLoading
|
||||
{
|
||||
self.refreshButton = self.stopLoadingButton
|
||||
}
|
||||
else
|
||||
{
|
||||
self.refreshButton = self.reloadButton
|
||||
}
|
||||
|
||||
self.backButton.isEnabled = self.webView.canGoBack
|
||||
self.forwardButton.isEnabled = self.webView.canGoForward
|
||||
|
||||
self.navigationItem.leftBarButtonItem = self.doneButton
|
||||
self.navigationItem.rightBarButtonItem = self.refreshButton
|
||||
|
||||
self.toolbarItems = [self.backButton, .fixedSpace(70), self.forwardButton, .flexibleSpace(), self.shareButton]
|
||||
}
|
||||
|
||||
func updateProgress(_ progress: Double)
|
||||
{
|
||||
if self.progressView.isHidden
|
||||
{
|
||||
self.showProgressBar(animated: true)
|
||||
}
|
||||
|
||||
if self.ignoreUpdateProgress
|
||||
{
|
||||
self.ignoreUpdateProgress = false
|
||||
self.hideProgressBar(animated: true)
|
||||
}
|
||||
else if progress < Double(self.progressView.progress)
|
||||
{
|
||||
// If progress is less than self.progressView.progress, another webpage began to load before the first one completed
|
||||
// In this case, we set the progress back to 0.0, and then wait until the next updateProgress, because it results in a much better animation
|
||||
|
||||
self.progressView.setProgress(0.0, animated: false)
|
||||
}
|
||||
else
|
||||
{
|
||||
UIView.animate(withDuration: 0.4, animations: {
|
||||
self.progressView.setProgress(Float(progress), animated: true)
|
||||
}, completion: { (finished) in
|
||||
if progress == 1.0
|
||||
{
|
||||
// This delay serves two purposes. One, it keeps the progress bar on screen just a bit longer so it doesn't appear to disappear too quickly.
|
||||
// Two, it allows us to prevent the progress bar from disappearing if the user actually started loading another webpage before the current one finished loading.
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
if self.webView.estimatedProgress == 1.0
|
||||
{
|
||||
self.hideProgressBar(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func showProgressBar(animated: Bool)
|
||||
{
|
||||
let navigationBarBounds = self.navigationController?.navigationBar.bounds ?? .zero
|
||||
self.progressView.frame = CGRect(x: 0, y: navigationBarBounds.height - self.progressView.bounds.height, width: navigationBarBounds.width, height: self.progressView.bounds.height)
|
||||
|
||||
self.navigationController?.navigationBar.addSubview(self.progressView)
|
||||
|
||||
self.progressView.setProgress(Float(self.webView.estimatedProgress), animated: false)
|
||||
self.progressView.isHidden = false
|
||||
|
||||
if animated
|
||||
{
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
self.progressView.alpha = 1.0
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.progressView.alpha = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
func hideProgressBar(animated: Bool)
|
||||
{
|
||||
if animated
|
||||
{
|
||||
UIView.animate(withDuration: 0.4, animations: {
|
||||
self.progressView.alpha = 0.0
|
||||
}, completion: { (finished) in
|
||||
self.progressView.setProgress(0.0, animated: false)
|
||||
self.progressView.isHidden = true
|
||||
self.progressView.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
else
|
||||
{
|
||||
self.progressView.alpha = 0.0
|
||||
|
||||
// Completion
|
||||
self.progressView.setProgress(0.0, animated: false)
|
||||
self.progressView.isHidden = true
|
||||
self.progressView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private extension WebViewController
|
||||
{
|
||||
func goBack(_ sender: UIBarButtonItem)
|
||||
{
|
||||
self.webView.goBack()
|
||||
}
|
||||
|
||||
func goForward(_ sender: UIBarButtonItem)
|
||||
{
|
||||
self.webView.goForward()
|
||||
}
|
||||
|
||||
func refresh(_ sender: UIBarButtonItem)
|
||||
{
|
||||
if self.webView.isLoading
|
||||
{
|
||||
self.ignoreUpdateProgress = true
|
||||
self.webView.stopLoading()
|
||||
}
|
||||
else
|
||||
{
|
||||
if let initialRequest = self.initialReqest, self.webView.url == nil && self.webView.backForwardList.backList.count == 0
|
||||
{
|
||||
self.webView.load(initialRequest)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.webView.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shareLink(_ sender: UIBarButtonItem)
|
||||
{
|
||||
let url = self.webView.url ?? (NSURL() as URL)
|
||||
|
||||
let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
activityViewController.modalPresentationStyle = .popover
|
||||
activityViewController.popoverPresentationController?.barButtonItem = sender
|
||||
self.present(activityViewController, animated: true)
|
||||
}
|
||||
|
||||
func dismissWebViewController(_ sender: UIBarButtonItem)
|
||||
{
|
||||
self.delegate?.webViewControllerDidFinish(self)
|
||||
|
||||
self.parent?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
60
AltStoreCore/Extensions/URL+Normalized.swift
Normal file
60
AltStoreCore/Extensions/URL+Normalized.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// URL+Normalized.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 11/2/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension URL
|
||||
{
|
||||
func normalized() throws -> String
|
||||
{
|
||||
// Based on https://encyclopedia.pub/entry/29841
|
||||
|
||||
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: self, NSURLErrorFailingURLErrorKey: self]) }
|
||||
|
||||
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://" + self.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: self, NSURLErrorFailingURLErrorKey: self]) }
|
||||
components = updatedComponents
|
||||
}
|
||||
|
||||
// 1. Don't use percent encoding
|
||||
guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: self, NSURLErrorFailingURLErrorKey: self]) }
|
||||
|
||||
// 2. Ignore scheme
|
||||
var normalizedURL = host
|
||||
|
||||
// 3. Add port (if not default)
|
||||
if let port = components.port, port != 80 && port != 443
|
||||
{
|
||||
normalizedURL += ":" + 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.
|
||||
normalizedURL += path // path has leading `/`
|
||||
|
||||
// 6. Convert to lowercase
|
||||
normalizedURL = normalizedURL.lowercased()
|
||||
|
||||
// 7. Remove trailing `/`
|
||||
if normalizedURL.hasSuffix("/")
|
||||
{
|
||||
normalizedURL.removeLast()
|
||||
}
|
||||
|
||||
// 8. Remove leading "www"
|
||||
if normalizedURL.hasPrefix("www.")
|
||||
{
|
||||
normalizedURL.removeFirst(4)
|
||||
}
|
||||
|
||||
return normalizedURL
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ public extension UserDefaults
|
||||
|
||||
@NSManaged var patronsRefreshID: String?
|
||||
|
||||
@NSManaged var skipPatreonDownloads: Bool
|
||||
|
||||
@nonobjc
|
||||
var activeAppsLimit: Int? {
|
||||
get {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||
<attribute name="appleID" attributeType="String"/>
|
||||
<attribute name="firstName" attributeType="String"/>
|
||||
@@ -154,6 +154,7 @@
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="isPatron" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<relationship name="pledges" toMany="YES" deletionRule="Cascade" destinationEntity="Pledge" inverseName="account" inverseEntity="Pledge"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
@@ -169,6 +170,40 @@
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Pledge" representedClassName="Pledge" syncable="YES">
|
||||
<attribute name="amount" attributeType="Decimal" defaultValueString="0"/>
|
||||
<attribute name="campaignURL" attributeType="URI"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PatreonAccount" inverseName="pledges" inverseEntity="PatreonAccount"/>
|
||||
<relationship name="rewards" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="PledgeReward" inverseName="pledge" inverseEntity="PledgeReward"/>
|
||||
<relationship name="tiers" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="PledgeTier" inverseName="pledge" inverseEntity="PledgeTier"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="PledgeReward" representedClassName="PledgeReward" syncable="YES">
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<relationship name="pledge" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Pledge" inverseName="rewards" inverseEntity="Pledge"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="PledgeTier" representedClassName="PledgeTier" syncable="YES">
|
||||
<attribute name="amount" attributeType="Decimal" defaultValueString="0.0"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<relationship name="pledge" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Pledge" inverseName="tiers" inverseEntity="Pledge"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="RefreshAttempt" representedClassName="RefreshAttempt" syncable="YES">
|
||||
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="errorDescription" optional="YES" attributeType="String"/>
|
||||
@@ -188,6 +223,7 @@
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="localizedDescription" optional="YES" attributeType="String"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="patreonURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="sourceURL" attributeType="URI"/>
|
||||
<attribute name="subtitle" optional="YES" attributeType="String"/>
|
||||
<attribute name="tintColor" optional="YES" attributeType="Transformable" valueTransformerName="ALTSecureValueTransformer"/>
|
||||
@@ -207,8 +243,13 @@
|
||||
<attribute name="downloadURL" attributeType="URI"/>
|
||||
<attribute name="iconURL" attributeType="URI"/>
|
||||
<attribute name="isBeta" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isHiddenWithoutPledge" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isPledged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isPledgeRequired" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="localizedDescription" attributeType="String"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="pledgeAmount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="pledgeCurrency" optional="YES" attributeType="String"/>
|
||||
<attribute name="screenshotURLs" attributeType="Transformable" valueTransformerName="ALTSecureValueTransformer"/>
|
||||
<attribute name="size" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="sortIndex" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
|
||||
@@ -226,7 +226,7 @@ public extension DatabaseManager
|
||||
|
||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(PatreonAccount.identifier), patreonAccountID)
|
||||
|
||||
let patreonAccount = PatreonAccount.first(satisfying: predicate, in: context)
|
||||
let patreonAccount = PatreonAccount.first(satisfying: predicate, in: context, requestProperties: [\.relationshipKeyPathsForPrefetching: [#keyPath(PatreonAccount._pledges)]])
|
||||
return patreonAccount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +194,19 @@ public extension InstalledApp
|
||||
// We have to also check !(latestSupportedVersion.buildVersion == '' && installedApp.storeBuildVersion == nil)
|
||||
// because latestSupportedVersion.buildVersion stores an empty string for nil, while installedApp.storeBuildVersion uses NULL.
|
||||
"(%K != %K OR (%K != %K AND NOT (%K == '' AND %K == nil)))",
|
||||
|
||||
"AND",
|
||||
|
||||
// !isPledgeRequired || isPledged
|
||||
"(%K == NO OR %K == YES)"
|
||||
].joined(separator: " ")
|
||||
|
||||
fetchRequest.predicate = NSPredicate(format: predicateFormat,
|
||||
#keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.latestSupportedVersion),
|
||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version), #keyPath(InstalledApp.version),
|
||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion),
|
||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion))
|
||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion),
|
||||
#keyPath(InstalledApp.storeApp.isPledgeRequired), #keyPath(InstalledApp.storeApp.isPledged))
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
@@ -227,17 +233,12 @@ public extension InstalledApp
|
||||
|
||||
class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp]
|
||||
{
|
||||
var predicate = NSPredicate(format: "%K == YES AND %K != %@", #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
|
||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
||||
{
|
||||
// No additional predicate
|
||||
}
|
||||
else
|
||||
{
|
||||
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
|
||||
NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
|
||||
}
|
||||
let predicate = NSPredicate(format: "(%K == YES AND %K != %@) AND (%K == nil OR %K == NO OR %K == YES)",
|
||||
#keyPath(InstalledApp.isActive),
|
||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID,
|
||||
#keyPath(InstalledApp.storeApp),
|
||||
#keyPath(InstalledApp.storeApp.isPledgeRequired),
|
||||
#keyPath(InstalledApp.storeApp.isPledged))
|
||||
|
||||
var installedApps = InstalledApp.all(satisfying: predicate,
|
||||
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
|
||||
@@ -246,8 +247,21 @@ public extension InstalledApp
|
||||
if let altStoreApp = InstalledApp.fetchAltStore(in: context)
|
||||
{
|
||||
// Refresh AltStore last since it causes app to quit.
|
||||
|
||||
if let storeApp = altStoreApp.storeApp
|
||||
{
|
||||
if !storeApp.isPledgeRequired || storeApp.isPledged
|
||||
{
|
||||
// Only add AltStore if it's the public version OR if it's the beta and we're pledged to it.
|
||||
installedApps.append(altStoreApp)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No associated storeApp, so add it just to be safe.
|
||||
installedApps.append(altStoreApp)
|
||||
}
|
||||
}
|
||||
|
||||
return installedApps
|
||||
}
|
||||
@@ -257,20 +271,14 @@ public extension InstalledApp
|
||||
// Date 6 hours before now.
|
||||
let date = Date().addingTimeInterval(-1 * 6 * 60 * 60)
|
||||
|
||||
var predicate = NSPredicate(format: "(%K == YES) AND (%K < %@) AND (%K != %@)",
|
||||
let predicate = NSPredicate(format: "(%K == YES) AND (%K < %@) AND (%K != %@) AND (%K == nil OR %K == NO OR %K == YES)",
|
||||
#keyPath(InstalledApp.isActive),
|
||||
#keyPath(InstalledApp.refreshedDate), date as NSDate,
|
||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
|
||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
||||
{
|
||||
// No additional predicate
|
||||
}
|
||||
else
|
||||
{
|
||||
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
|
||||
NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
|
||||
}
|
||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID,
|
||||
#keyPath(InstalledApp.storeApp),
|
||||
#keyPath(InstalledApp.storeApp.isPledgeRequired),
|
||||
#keyPath(InstalledApp.storeApp.isPledged)
|
||||
)
|
||||
|
||||
var installedApps = InstalledApp.all(satisfying: predicate,
|
||||
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
|
||||
@@ -278,9 +286,20 @@ public extension InstalledApp
|
||||
|
||||
if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date
|
||||
{
|
||||
// Refresh AltStore last since it may cause app to quit.
|
||||
if let storeApp = altStoreApp.storeApp
|
||||
{
|
||||
if !storeApp.isPledgeRequired || storeApp.isPledged
|
||||
{
|
||||
// Only add AltStore if it's the public version OR if it's the beta and we're pledged to it.
|
||||
installedApps.append(altStoreApp)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No associated storeApp, so add it just to be safe.
|
||||
installedApps.append(altStoreApp)
|
||||
}
|
||||
}
|
||||
|
||||
return installedApps
|
||||
}
|
||||
@@ -298,6 +317,13 @@ public extension InstalledApp
|
||||
let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")!
|
||||
return openAppURL
|
||||
}
|
||||
|
||||
var isUpdateAvailable: Bool {
|
||||
guard let storeApp = self.storeApp, let latestVersion = storeApp.latestSupportedVersion else { return false }
|
||||
|
||||
let isUpdateAvailable = !self.matches(latestVersion)
|
||||
return isUpdateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
public extension InstalledApp
|
||||
|
||||
@@ -107,7 +107,7 @@ public extension LoggedError
|
||||
{
|
||||
var app: AppProtocol {
|
||||
// `as AppProtocol` needed to fix "cannot convert AnyApp to StoreApp" compiler error with Xcode 14.
|
||||
let app = self.installedApp ?? self.storeApp ?? AnyApp(name: self.appName, bundleIdentifier: self.appBundleID, url: nil) as AppProtocol
|
||||
let app = self.installedApp ?? self.storeApp ?? AnyApp(name: self.appName, bundleIdentifier: self.appBundleID, url: nil, storeApp: nil) as AppProtocol
|
||||
return app
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public class ManagedPatron: NSManagedObject, Fetchable
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
public init?(patron: Patron, context: NSManagedObjectContext)
|
||||
public init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
|
||||
{
|
||||
// Only cache Patrons with non-nil names.
|
||||
guard let name = patron.name else { return nil }
|
||||
|
||||
@@ -263,6 +263,35 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
featuredAppIDsBySourceID[databaseObject.identifier] = contextSource.featuredApps?.map { $0.bundleIdentifier }
|
||||
}
|
||||
|
||||
case let databasePledge as Pledge:
|
||||
guard let contextPledge = conflict.conflictingObjects.first as? Pledge else { break }
|
||||
|
||||
// Tiers
|
||||
let contextTierIDs = Set(contextPledge._tiers.lazy.compactMap { $0 as? PledgeTier }.map { $0.identifier })
|
||||
for case let databaseTier as PledgeTier in databasePledge._tiers where !contextTierIDs.contains(databaseTier.identifier)
|
||||
{
|
||||
// Tier ID does NOT exist in context, so delete existing databaseTier.
|
||||
databaseTier.managedObjectContext?.delete(databaseTier)
|
||||
}
|
||||
|
||||
// Rewards
|
||||
let contextRewardIDs = Set(contextPledge._rewards.lazy.compactMap { $0 as? PledgeReward }.map { $0.identifier })
|
||||
for case let databaseReward as PledgeReward in databasePledge._rewards where !contextRewardIDs.contains(databaseReward.identifier)
|
||||
{
|
||||
// Reward ID does NOT exist in context, so delete existing databaseReward.
|
||||
databaseReward.managedObjectContext?.delete(databaseReward)
|
||||
}
|
||||
|
||||
case let databaseAccount as PatreonAccount:
|
||||
guard let contextAccount = conflict.conflictingObjects.first as? PatreonAccount else { break }
|
||||
|
||||
let contextPledgeIDs = Set(contextAccount._pledges.lazy.compactMap { $0 as? Pledge }.map { $0.identifier })
|
||||
for case let databasePledge as Pledge in databaseAccount._pledges where !contextPledgeIDs.contains(databasePledge.identifier)
|
||||
{
|
||||
// Pledge ID does NOT exist in context, so delete existing databasePledge.
|
||||
databasePledge.managedObjectContext?.delete(databasePledge)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
75
AltStoreCore/Model/Patreon/PatreonAccount.swift
Normal file
75
AltStoreCore/Model/Patreon/PatreonAccount.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// PatreonAccount.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/20/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
@objc(PatreonAccount)
|
||||
public class PatreonAccount: NSManagedObject, Fetchable
|
||||
{
|
||||
@NSManaged public var identifier: String
|
||||
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var firstName: String?
|
||||
|
||||
// Use `isPatron` for backwards compatibility.
|
||||
@NSManaged @objc(isPatron) public var isAltStorePatron: Bool
|
||||
|
||||
/* Relationships */
|
||||
@nonobjc public var pledges: Set<Pledge> { _pledges as! Set<Pledge> }
|
||||
@NSManaged @objc(pledges) internal var _pledges: NSSet
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PatreonAccount.entity(), insertInto: context)
|
||||
|
||||
self.identifier = account.identifier
|
||||
self.name = account.name
|
||||
self.firstName = account.firstName
|
||||
|
||||
let pledges = account.pledges?.compactMap { patron -> Pledge? in
|
||||
// First ensure pledge is active.
|
||||
guard patron.status == .active else { return nil }
|
||||
|
||||
guard let pledge = Pledge(patron: patron, context: context) else { return nil }
|
||||
|
||||
let tiers = patron.tiers.map { PledgeTier(tier: $0, context: context) }
|
||||
pledge._tiers = Set(tiers) as NSSet
|
||||
|
||||
let rewards = patron.benefits.map { PledgeReward(benefit: $0, context: context) }
|
||||
pledge._rewards = Set(rewards) as NSSet
|
||||
|
||||
return pledge
|
||||
} ?? []
|
||||
|
||||
self._pledges = Set(pledges) as NSSet
|
||||
|
||||
if let altstorePledge = account.pledges?.first(where: { $0.campaign?.identifier == PatreonAPI.altstoreCampaignID })
|
||||
{
|
||||
let isActivePatron = (altstorePledge.status == .active)
|
||||
self.isAltStorePatron = isActivePatron
|
||||
}
|
||||
else
|
||||
{
|
||||
self.isAltStorePatron = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PatreonAccount
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PatreonAccount>
|
||||
{
|
||||
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
|
||||
}
|
||||
}
|
||||
|
||||
54
AltStoreCore/Model/Patreon/Pledge.swift
Normal file
54
AltStoreCore/Model/Patreon/Pledge.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Pledge.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Pledge)
|
||||
public class Pledge: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var identifier: String
|
||||
@NSManaged public private(set) var campaignURL: URL
|
||||
|
||||
@nonobjc public var amount: Decimal { _amount as Decimal }
|
||||
@NSManaged @objc(amount) private var _amount: NSDecimalNumber
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public private(set) var account: PatreonAccount?
|
||||
|
||||
@nonobjc public var tiers: Set<PledgeTier> { _tiers as! Set<PledgeTier> }
|
||||
@NSManaged @objc(tiers) internal var _tiers: NSSet
|
||||
|
||||
@nonobjc public var rewards: Set<PledgeReward> { _rewards as! Set<PledgeReward> }
|
||||
@NSManaged @objc(rewards) internal var _rewards: NSSet
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
|
||||
{
|
||||
guard let amount = patron.pledgeAmount, let campaignURL = patron.campaign?.url else { return nil }
|
||||
|
||||
super.init(entity: Pledge.entity(), insertInto: context)
|
||||
|
||||
self.identifier = patron.identifier
|
||||
self._amount = amount as NSDecimalNumber
|
||||
self.campaignURL = campaignURL
|
||||
}
|
||||
}
|
||||
|
||||
public extension Pledge
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Pledge>
|
||||
{
|
||||
return NSFetchRequest<Pledge>(entityName: "Pledge")
|
||||
}
|
||||
}
|
||||
42
AltStoreCore/Model/Patreon/PledgeReward.swift
Normal file
42
AltStoreCore/Model/Patreon/PledgeReward.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// PledgeReward.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PledgeReward)
|
||||
public class PledgeReward: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var name: String
|
||||
@NSManaged public private(set) var identifier: String
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public private(set) var pledge: Pledge?
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(benefit: PatreonAPI.Benefit, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PledgeReward.entity(), insertInto: context)
|
||||
|
||||
self.name = benefit.name
|
||||
self.identifier = benefit.identifier.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
public extension PledgeReward
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PledgeReward>
|
||||
{
|
||||
return NSFetchRequest<PledgeReward>(entityName: "PledgeReward")
|
||||
}
|
||||
}
|
||||
46
AltStoreCore/Model/Patreon/PledgeTier.swift
Normal file
46
AltStoreCore/Model/Patreon/PledgeTier.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// PledgeTier.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PledgeTier)
|
||||
public class PledgeTier: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var name: String
|
||||
@NSManaged public private(set) var identifier: String
|
||||
|
||||
@nonobjc public var amount: Decimal { _amount as Decimal } // In USD
|
||||
@NSManaged @objc(amount) private var _amount: NSDecimalNumber
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public private(set) var pledge: Pledge?
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(tier: PatreonAPI.Tier, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PledgeTier.entity(), insertInto: context)
|
||||
|
||||
self.name = tier.name
|
||||
self.identifier = tier.identifier
|
||||
self._amount = tier.amount as NSDecimalNumber
|
||||
}
|
||||
}
|
||||
|
||||
public extension PledgeTier
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PledgeTier>
|
||||
{
|
||||
return NSFetchRequest<PledgeTier>(entityName: "PledgeTier")
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// PatreonAccount.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/20/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct AccountResponse: Decodable
|
||||
{
|
||||
struct Data: Decodable
|
||||
{
|
||||
struct Attributes: Decodable
|
||||
{
|
||||
var first_name: String?
|
||||
var full_name: String
|
||||
}
|
||||
|
||||
var id: String
|
||||
var attributes: Attributes
|
||||
}
|
||||
|
||||
var data: Data
|
||||
var included: [PatronResponse]?
|
||||
}
|
||||
}
|
||||
|
||||
@objc(PatreonAccount)
|
||||
public class PatreonAccount: NSManagedObject, Fetchable
|
||||
{
|
||||
@NSManaged public var identifier: String
|
||||
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var firstName: String?
|
||||
|
||||
@NSManaged public var isPatron: Bool
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(response: PatreonAPI.AccountResponse, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PatreonAccount.entity(), insertInto: context)
|
||||
|
||||
self.identifier = response.data.id
|
||||
self.name = response.data.attributes.full_name
|
||||
self.firstName = response.data.attributes.first_name
|
||||
|
||||
if let patronResponse = response.included?.first
|
||||
{
|
||||
let patron = Patron(response: patronResponse)
|
||||
self.isPatron = (patron.status == .active)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.isPatron = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PatreonAccount
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PatreonAccount>
|
||||
{
|
||||
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,9 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
|
||||
/* Source Detail */
|
||||
@NSManaged public var subtitle: String?
|
||||
@NSManaged public var websiteURL: URL?
|
||||
@NSManaged public var localizedDescription: String?
|
||||
@NSManaged public var websiteURL: URL?
|
||||
@NSManaged public var patreonURL: URL?
|
||||
|
||||
// Optional properties with fallbacks.
|
||||
// `private` to prevent accidentally using instead of `effective[PropertyName]`
|
||||
@@ -117,6 +118,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
case headerImageURL = "headerURL"
|
||||
case websiteURL = "website"
|
||||
case tintColor
|
||||
case patreonURL
|
||||
|
||||
case apps
|
||||
case news
|
||||
@@ -147,6 +149,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription)
|
||||
self.iconURL = try container.decodeIfPresent(URL.self, forKey: .iconURL)
|
||||
self.headerImageURL = try container.decodeIfPresent(URL.self, forKey: .headerImageURL)
|
||||
self.patreonURL = try container.decodeIfPresent(URL.self, forKey: .patreonURL)
|
||||
|
||||
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
|
||||
{
|
||||
@@ -252,50 +255,8 @@ 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
|
||||
let sourceID = try sourceURL.normalized()
|
||||
return sourceID
|
||||
}
|
||||
|
||||
func setFeaturedApps(_ featuredApps: [StoreApp]?)
|
||||
|
||||
@@ -25,6 +25,18 @@ public extension StoreApp
|
||||
static let dolphinAppID = "me.oatmealdome.dolphinios-njb"
|
||||
}
|
||||
|
||||
extension StoreApp
|
||||
{
|
||||
private struct PatreonParameters: Decodable
|
||||
{
|
||||
var pledge: Decimal?
|
||||
var currency: String?
|
||||
var tiers: Set<String>?
|
||||
var benefit: String?
|
||||
var hidden: Bool?
|
||||
}
|
||||
}
|
||||
|
||||
@objc(StoreApp)
|
||||
public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
{
|
||||
@@ -44,6 +56,14 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
@NSManaged public private(set) var tintColor: UIColor?
|
||||
@NSManaged public private(set) var isBeta: Bool
|
||||
|
||||
@NSManaged public var isPledged: Bool
|
||||
@NSManaged public private(set) var isPledgeRequired: Bool
|
||||
@NSManaged public private(set) var isHiddenWithoutPledge: Bool
|
||||
@NSManaged public private(set) var pledgeCurrency: String?
|
||||
|
||||
@nonobjc public var pledgeAmount: Decimal? { _pledgeAmount as? Decimal }
|
||||
@NSManaged @objc(pledgeAmount) private var _pledgeAmount: NSDecimalNumber?
|
||||
|
||||
@NSManaged public var sortIndex: Int32
|
||||
|
||||
@objc public internal(set) var sourceIdentifier: String? {
|
||||
@@ -94,6 +114,12 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
|
||||
@NSManaged public private(set) var loggedErrors: NSSet /* Set<LoggedError> */ // Use NSSet to avoid eagerly fetching values.
|
||||
|
||||
/* Non-Core Data Properties */
|
||||
|
||||
// Used to set isPledged after fetching source.
|
||||
public var _tierIDs: Set<String>?
|
||||
public var _rewardID: String?
|
||||
|
||||
@nonobjc public var source: Source? {
|
||||
set {
|
||||
self._source = newValue
|
||||
@@ -137,6 +163,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
case size
|
||||
case isBeta = "beta"
|
||||
case versions
|
||||
case patreon
|
||||
|
||||
// Legacy
|
||||
case version
|
||||
@@ -247,6 +274,39 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
in: context)
|
||||
try self.setVersions([appVersion])
|
||||
}
|
||||
|
||||
// Must _explicitly_ set to false to ensure it updates cached database value.
|
||||
self.isPledged = false
|
||||
|
||||
if let patreon = try container.decodeIfPresent(PatreonParameters.self, forKey: .patreon)
|
||||
{
|
||||
self.isPledgeRequired = true
|
||||
self.isHiddenWithoutPledge = patreon.hidden ?? false // Default to showing Patreon apps
|
||||
|
||||
if let pledge = patreon.pledge
|
||||
{
|
||||
self._pledgeAmount = pledge as NSDecimalNumber
|
||||
self.pledgeCurrency = patreon.currency ?? "USD" // Only set pledge currency if explicitly given pledge.
|
||||
}
|
||||
else if patreon.pledge == nil && patreon.tiers == nil && patreon.benefit == nil
|
||||
{
|
||||
// No conditions, so default to pledgeAmount of 0 to simplify logic.
|
||||
self._pledgeAmount = 0 as NSDecimalNumber
|
||||
}
|
||||
|
||||
self._tierIDs = patreon.tiers
|
||||
self._rewardID = patreon.benefit
|
||||
}
|
||||
else
|
||||
{
|
||||
self.isPledgeRequired = false
|
||||
self.isHiddenWithoutPledge = false
|
||||
self._pledgeAmount = nil
|
||||
self.pledgeCurrency = nil
|
||||
|
||||
self._tierIDs = nil
|
||||
self._rewardID = nil
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -376,6 +436,18 @@ public extension StoreApp
|
||||
let globallyUniqueID = self.bundleIdentifier + "|" + sourceIdentifier
|
||||
return globallyUniqueID
|
||||
}
|
||||
}
|
||||
|
||||
public extension StoreApp
|
||||
{
|
||||
class var visibleAppsPredicate: NSPredicate {
|
||||
let predicate = NSPredicate(format: "(%K != %@) AND ((%K == NO) OR (%K == NO) OR (%K == YES))",
|
||||
#keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID,
|
||||
#keyPath(StoreApp.isPledgeRequired),
|
||||
#keyPath(StoreApp.isHiddenWithoutPledge),
|
||||
#keyPath(StoreApp.isPledged))
|
||||
return predicate
|
||||
}
|
||||
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<StoreApp>
|
||||
{
|
||||
|
||||
@@ -10,18 +10,25 @@ import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct BenefitResponse: Decodable
|
||||
typealias BenefitResponse = DataResponse<BenefitAttributes, AnyRelationships>
|
||||
|
||||
struct BenefitAttributes: Decodable
|
||||
{
|
||||
var id: String
|
||||
var title: String
|
||||
}
|
||||
}
|
||||
|
||||
public struct Benefit: Hashable
|
||||
extension PatreonAPI
|
||||
{
|
||||
public var type: ALTPatreonBenefitType
|
||||
|
||||
init(response: PatreonAPI.BenefitResponse)
|
||||
public struct Benefit: Hashable
|
||||
{
|
||||
self.type = ALTPatreonBenefitType(response.id)
|
||||
public var name: String
|
||||
public var identifier: ALTPatreonBenefitID
|
||||
|
||||
internal init(response: BenefitResponse)
|
||||
{
|
||||
self.name = response.attributes.title
|
||||
self.identifier = ALTPatreonBenefitID(response.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,25 @@ import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct CampaignResponse: Decodable
|
||||
typealias CampaignResponse = DataResponse<CampaignAttributes, AnyRelationships>
|
||||
|
||||
struct CampaignAttributes: Decodable
|
||||
{
|
||||
var id: String
|
||||
var url: URL
|
||||
}
|
||||
}
|
||||
|
||||
public struct Campaign
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct Campaign
|
||||
{
|
||||
public var identifier: String
|
||||
public var url: URL
|
||||
|
||||
init(response: PatreonAPI.CampaignResponse)
|
||||
internal init(response: PatreonAPI.CampaignResponse)
|
||||
{
|
||||
self.identifier = response.id
|
||||
self.url = response.attributes.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
161
AltStoreCore/Patreon/PatreonAPI+Responses.swift
Normal file
161
AltStoreCore/Patreon/PatreonAPI+Responses.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// PatreonAPI+Responses.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 11/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ResponseData: Decodable
|
||||
{
|
||||
}
|
||||
|
||||
// Allows us to use Arrays with Response<> despite them not conforming to `ItemResponse`
|
||||
extension Array: ResponseData where Element: ItemResponse
|
||||
{
|
||||
}
|
||||
|
||||
protocol ItemResponse: ResponseData
|
||||
{
|
||||
var id: String { get }
|
||||
var type: String { get }
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct Response<Data: ResponseData>: Decodable
|
||||
{
|
||||
var data: Data
|
||||
|
||||
var included: IncludedResponses?
|
||||
var links: [String: URL]?
|
||||
}
|
||||
|
||||
struct AnyItemResponse: ItemResponse
|
||||
{
|
||||
var id: String
|
||||
var type: String
|
||||
}
|
||||
|
||||
struct DataResponse<Attributes: Decodable, Relationships: Decodable>: ItemResponse
|
||||
{
|
||||
var id: String
|
||||
var type: String
|
||||
|
||||
var attributes: Attributes
|
||||
var relationships: Relationships?
|
||||
}
|
||||
|
||||
// `Never` only conforms to Decodable from iOS 17 onwards,
|
||||
// so use our own "Empty" type for DataResponses without relationships.
|
||||
struct AnyRelationships: Decodable
|
||||
{
|
||||
}
|
||||
|
||||
struct IncludedResponses: Decodable
|
||||
{
|
||||
var items: [IncludedItem]
|
||||
|
||||
var campaigns: [String: CampaignResponse]
|
||||
var patrons: [String: PatronResponse]
|
||||
var tiers: [String: TierResponse]
|
||||
var benefits: [String: BenefitResponse]
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.items = try container.decode([IncludedItem].self)
|
||||
|
||||
var campaignsByID = [String: PatreonAPI.CampaignResponse]()
|
||||
var patronsByID = [String: PatreonAPI.PatronResponse]()
|
||||
var tiersByID = [String: PatreonAPI.TierResponse]()
|
||||
var benefitsByID = [String: PatreonAPI.BenefitResponse]()
|
||||
|
||||
for response in self.items
|
||||
{
|
||||
switch response
|
||||
{
|
||||
case .campaign(let response): campaignsByID[response.id] = response
|
||||
case .patron(let response): patronsByID[response.id] = response
|
||||
case .tier(let response): tiersByID[response.id] = response
|
||||
case .benefit(let response): benefitsByID[response.id] = response
|
||||
case .unknown: break // Ignore
|
||||
}
|
||||
}
|
||||
|
||||
self.campaigns = campaignsByID
|
||||
self.patrons = patronsByID
|
||||
self.tiers = tiersByID
|
||||
self.benefits = benefitsByID
|
||||
}
|
||||
}
|
||||
|
||||
enum IncludedItem: ItemResponse
|
||||
{
|
||||
case tier(TierResponse)
|
||||
case benefit(BenefitResponse)
|
||||
case patron(PatronResponse)
|
||||
case campaign(CampaignResponse)
|
||||
case unknown(AnyItemResponse)
|
||||
|
||||
var id: String {
|
||||
switch self
|
||||
{
|
||||
case .tier(let response): return response.id
|
||||
case .benefit(let response): return response.id
|
||||
case .patron(let response): return response.id
|
||||
case .campaign(let response): return response.id
|
||||
case .unknown(let response): return response.id
|
||||
}
|
||||
}
|
||||
|
||||
var type: String {
|
||||
switch self
|
||||
{
|
||||
case .tier(let response): return response.type
|
||||
case .benefit(let response): return response.type
|
||||
case .patron(let response): return response.type
|
||||
case .campaign(let response): return response.type
|
||||
case .unknown(let response): return response.type
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case type
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
switch type
|
||||
{
|
||||
case "tier":
|
||||
let response = try TierResponse(from: decoder)
|
||||
self = .tier(response)
|
||||
|
||||
case "benefit":
|
||||
let response = try BenefitResponse(from: decoder)
|
||||
self = .benefit(response)
|
||||
|
||||
case "member":
|
||||
let response = try PatronResponse(from: decoder)
|
||||
self = .patron(response)
|
||||
|
||||
case "campaign":
|
||||
let response = try CampaignResponse(from: decoder)
|
||||
self = .campaign(response)
|
||||
|
||||
default:
|
||||
Logger.main.error("Unrecognized PatreonAPI response type: \(type, privacy: .public).")
|
||||
|
||||
let response = try AnyItemResponse(from: decoder)
|
||||
self = .unknown(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,11 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import CoreData
|
||||
import WebKit
|
||||
|
||||
private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2"
|
||||
private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt"
|
||||
|
||||
private let campaignID = "2863968"
|
||||
|
||||
typealias PatreonAPIError = PatreonAPIErrorCode.Error
|
||||
enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
|
||||
{
|
||||
@@ -34,42 +33,17 @@ enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
static let altstoreCampaignID = "2863968"
|
||||
|
||||
typealias FetchAccountResponse = Response<UserAccountResponse>
|
||||
typealias FriendZonePatronsResponse = Response<[PatronResponse]>
|
||||
|
||||
enum AuthorizationType
|
||||
{
|
||||
case none
|
||||
case user
|
||||
case creator
|
||||
}
|
||||
|
||||
enum AnyResponse: Decodable
|
||||
{
|
||||
case tier(TierResponse)
|
||||
case benefit(BenefitResponse)
|
||||
|
||||
enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case type
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
switch type
|
||||
{
|
||||
case "tier":
|
||||
let tier = try TierResponse(from: decoder)
|
||||
self = .tier(tier)
|
||||
|
||||
case "benefit":
|
||||
let benefit = try BenefitResponse(from: decoder)
|
||||
self = .benefit(benefit)
|
||||
|
||||
default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unrecognized Patreon response type.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PatreonAPI: NSObject
|
||||
@@ -85,6 +59,10 @@ public class PatreonAPI: NSObject
|
||||
private let session = URLSession(configuration: .ephemeral)
|
||||
private let baseURL = URL(string: "https://www.patreon.com/")!
|
||||
|
||||
private var authHandlers = [(Result<PatreonAccount, Swift.Error>) -> Void]()
|
||||
private var authContinuation: CheckedContinuation<URL, Error>?
|
||||
private weak var webViewController: WebViewController?
|
||||
|
||||
private override init()
|
||||
{
|
||||
super.init()
|
||||
@@ -93,19 +71,40 @@ public class PatreonAPI: NSObject
|
||||
|
||||
public extension PatreonAPI
|
||||
{
|
||||
func authenticate(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
||||
func authenticate(presentingViewController: UIViewController, completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
||||
{
|
||||
Task<Void, Never>.detached { @MainActor in
|
||||
guard self.authHandlers.isEmpty else {
|
||||
self.authHandlers.append(completion)
|
||||
return
|
||||
}
|
||||
|
||||
self.authHandlers.append(completion)
|
||||
|
||||
do
|
||||
{
|
||||
var components = URLComponents(string: "/oauth2/authorize")!
|
||||
components.queryItems = [URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore")]
|
||||
URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore"),
|
||||
URLQueryItem(name: "scope", value: "identity identity[email] identity.memberships campaigns.posts")]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
||||
let requestURL = components.url(relativeTo: self.baseURL)
|
||||
|
||||
self.authenticationSession = ASWebAuthenticationSession(url: requestURL, callbackURLScheme: "altstore") { (callbackURL, error) in
|
||||
do
|
||||
{
|
||||
let callbackURL = try Result(callbackURL, error).get()
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.setURLSchemeHandler(self, forURLScheme: "altstore")
|
||||
configuration.websiteDataStore = .default()
|
||||
|
||||
let webViewController = WebViewController(url: requestURL, configuration: configuration)
|
||||
webViewController.delegate = self
|
||||
self.webViewController = webViewController
|
||||
|
||||
let callbackURL = try await withCheckedThrowingContinuation { continuation in
|
||||
self.authContinuation = continuation
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
guard
|
||||
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
@@ -113,39 +112,61 @@ public extension PatreonAPI
|
||||
let code = codeQueryItem.value
|
||||
else { throw PatreonAPIError(.unknown) }
|
||||
|
||||
self.fetchAccessToken(oauthCode: code) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .success((let accessToken, let refreshToken)):
|
||||
let (accessToken, refreshToken) = try await withCheckedThrowingContinuation { continuation in
|
||||
self.fetchAccessToken(oauthCode: code) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
Keychain.shared.patreonAccessToken = accessToken
|
||||
Keychain.shared.patreonRefreshToken = refreshToken
|
||||
|
||||
self.fetchAccount(completion: completion)
|
||||
let patreonAccount = try await withCheckedThrowingContinuation { continuation in
|
||||
self.fetchAccount { result in
|
||||
let result = result.map { AsyncManaged(wrappedValue: $0) }
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
await self.saveAuthCookies()
|
||||
|
||||
await patreonAccount.perform { patreonAccount in
|
||||
for callback in self.authHandlers
|
||||
{
|
||||
callback(.success(patreonAccount))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completion(.failure(error))
|
||||
for callback in self.authHandlers
|
||||
{
|
||||
callback(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
self.authenticationSession?.presentationContextProvider = self
|
||||
self.authenticationSession?.start()
|
||||
self.authHandlers = []
|
||||
|
||||
await MainActor.run {
|
||||
self.webViewController?.dismiss(animated: true)
|
||||
self.webViewController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAccount(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
||||
{
|
||||
var components = URLComponents(string: "/api/oauth2/v2/identity")!
|
||||
components.queryItems = [URLQueryItem(name: "include", value: "memberships"),
|
||||
components.queryItems = [URLQueryItem(name: "include", value: "memberships.campaign.tiers,memberships.currently_entitled_tiers.benefits"),
|
||||
URLQueryItem(name: "fields[user]", value: "first_name,full_name"),
|
||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status")]
|
||||
URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
|
||||
URLQueryItem(name: "fields[benefit]", value: "title"),
|
||||
URLQueryItem(name: "fields[campaign]", value: "url"),
|
||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents")]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
||||
let request = URLRequest(url: requestURL)
|
||||
|
||||
self.send(request, authorizationType: .user) { (result: Result<AccountResponse, Swift.Error>) in
|
||||
self.send(request, authorizationType: .user) { (result: Result<FetchAccountResponse, Swift.Error>) in
|
||||
switch result
|
||||
{
|
||||
case .failure(~PatreonAPIErrorCode.notAuthenticated):
|
||||
@@ -153,10 +174,15 @@ public extension PatreonAPI
|
||||
completion(.failure(PatreonAPIError(.notAuthenticated)))
|
||||
}
|
||||
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .failure(let error as NSError):
|
||||
Logger.main.error("Failed to fetch Patreon account. \(error.localizedDebugDescription ?? error.localizedDescription, privacy: .public)")
|
||||
completion(.failure(error))
|
||||
|
||||
case .success(let response):
|
||||
let account = PatreonAPI.UserAccount(response: response.data, including: response.included)
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
let account = PatreonAccount(response: response, context: context)
|
||||
let account = PatreonAccount(account: account, context: context)
|
||||
Keychain.shared.patreonAccountID = account.identifier
|
||||
completion(.success(account))
|
||||
}
|
||||
@@ -166,57 +192,34 @@ public extension PatreonAPI
|
||||
|
||||
func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void)
|
||||
{
|
||||
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(campaignID)/members")!
|
||||
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(PatreonAPI.altstoreCampaignID)/members")!
|
||||
components.queryItems = [URLQueryItem(name: "include", value: "currently_entitled_tiers,currently_entitled_tiers.benefits"),
|
||||
URLQueryItem(name: "fields[tier]", value: "title"),
|
||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status"),
|
||||
URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
|
||||
URLQueryItem(name: "fields[benefit]", value: "title"),
|
||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents"),
|
||||
URLQueryItem(name: "page[size]", value: "1000")]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
||||
|
||||
struct Response: Decodable
|
||||
{
|
||||
var data: [PatronResponse]
|
||||
var included: [AnyResponse]
|
||||
var links: [String: URL]?
|
||||
}
|
||||
|
||||
var allPatrons = [Patron]()
|
||||
|
||||
func fetchPatrons(url: URL)
|
||||
{
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
self.send(request, authorizationType: .creator) { (result: Result<Response, Swift.Error>) in
|
||||
self.send(request, authorizationType: .creator) { (result: Result<FriendZonePatronsResponse, Swift.Error>) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .success(let response):
|
||||
let tiers = response.included.compactMap { (response) -> Tier? in
|
||||
switch response
|
||||
{
|
||||
case .tier(let tierResponse): return Tier(response: tierResponse)
|
||||
case .benefit: return nil
|
||||
}
|
||||
}
|
||||
|
||||
let tiersByIdentifier = Dictionary(tiers.map { ($0.identifier, $0) }, uniquingKeysWith: { (a, b) in return a })
|
||||
|
||||
let patrons = response.data.map { (response) -> Patron in
|
||||
let patron = Patron(response: response)
|
||||
|
||||
for tierID in response.relationships?.currently_entitled_tiers.data ?? []
|
||||
{
|
||||
guard let tier = tiersByIdentifier[tierID.id] else { continue }
|
||||
patron.benefits.formUnion(tier.benefits)
|
||||
}
|
||||
|
||||
case .success(let patronsResponse):
|
||||
let patrons = patronsResponse.data.map { (response) -> Patron in
|
||||
let patron = Patron(response: response, including: patronsResponse.included)
|
||||
return patron
|
||||
}.filter { $0.benefits.contains(where: { $0.type == .credits }) }
|
||||
}.filter { $0.benefits.contains(where: { $0.identifier == .credits }) }
|
||||
|
||||
allPatrons.append(contentsOf: patrons)
|
||||
|
||||
if let nextURL = response.links?["next"]
|
||||
if let nextURL = patronsResponse.links?["next"]
|
||||
{
|
||||
fetchPatrons(url: nextURL)
|
||||
}
|
||||
@@ -239,7 +242,8 @@ public extension PatreonAPI
|
||||
let accounts = PatreonAccount.all(in: context, requestProperties: [\.returnsObjectsAsFaults: true])
|
||||
accounts.forEach(context.delete(_:))
|
||||
|
||||
self.deactivateBetaApps(in: context)
|
||||
let pledgeRequiredApps = StoreApp.all(satisfying: NSPredicate(format: "%K == YES", #keyPath(StoreApp.isPledgeRequired)), in: context)
|
||||
pledgeRequiredApps.forEach { $0.isPledged = false }
|
||||
|
||||
try context.save()
|
||||
|
||||
@@ -247,8 +251,11 @@ public extension PatreonAPI
|
||||
Keychain.shared.patreonRefreshToken = nil
|
||||
Keychain.shared.patreonAccountID = nil
|
||||
|
||||
Task<Void, Never>.detached {
|
||||
await self.deleteAuthCookies()
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completion(.failure(error))
|
||||
@@ -264,13 +271,6 @@ public extension PatreonAPI
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
|
||||
if let context = account.managedObjectContext, !account.isPatron
|
||||
{
|
||||
// Deactivate all beta apps now that we're no longer a patron.
|
||||
self.deactivateBetaApps(in: context)
|
||||
}
|
||||
|
||||
try account.managedObjectContext?.save()
|
||||
}
|
||||
catch
|
||||
@@ -281,6 +281,56 @@ public extension PatreonAPI
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
private func saveAuthCookies() async
|
||||
{
|
||||
let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor
|
||||
|
||||
let cookies = await cookieStore.allCookies()
|
||||
for cookie in cookies where cookie.domain.lowercased().hasSuffix("patreon.com")
|
||||
{
|
||||
Logger.main.debug("Saving Patreon cookie \(cookie.name, privacy: .public): \(cookie.value, privacy: .private(mask: .hash)) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))")
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteAuthCookies() async
|
||||
{
|
||||
Logger.main.info("Clearing Patreon cookie cache...")
|
||||
|
||||
let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor
|
||||
|
||||
if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.patreon.com")!)
|
||||
{
|
||||
for cookie in cookies
|
||||
{
|
||||
Logger.main.debug("Deleting Patreon cookie \(cookie.name, privacy: .public) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))")
|
||||
|
||||
await cookieStore.deleteCookie(cookie)
|
||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||
}
|
||||
|
||||
Logger.main.info("Cleared Patreon cookie cache!")
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.main.info("No Patreon cookies to clear.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI: WebViewControllerDelegate
|
||||
{
|
||||
public func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
{
|
||||
guard let authContinuation else { return }
|
||||
self.authContinuation = nil
|
||||
|
||||
authContinuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
private extension PatreonAPI
|
||||
{
|
||||
func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void)
|
||||
@@ -397,36 +447,27 @@ private extension PatreonAPI
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func deactivateBetaApps(in context: NSManagedObjectContext)
|
||||
{
|
||||
let predicate = NSPredicate(format: "%K != %@ AND %K != nil AND %K == YES",
|
||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID, #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))
|
||||
|
||||
let installedApps = InstalledApp.all(satisfying: predicate, in: context)
|
||||
installedApps.forEach { $0.isActive = false }
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI: ASWebAuthenticationPresentationContextProviding
|
||||
extension PatreonAPI: WKURLSchemeHandler
|
||||
{
|
||||
public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask)
|
||||
{
|
||||
//TODO: Properly support multiple scenes.
|
||||
guard let authContinuation else { return }
|
||||
self.authContinuation = nil
|
||||
|
||||
guard let windowScene = UIApplication.alt_shared?.connectedScenes.lazy.compactMap({ $0 as? UIWindowScene }).first else { return UIWindow() }
|
||||
|
||||
if #available(iOS 15, *), let keyWindow = windowScene.keyWindow
|
||||
if let callbackURL = urlSchemeTask.request.url
|
||||
{
|
||||
return keyWindow
|
||||
authContinuation.resume(returning: callbackURL)
|
||||
}
|
||||
else if let delegate = windowScene.delegate as? UIWindowSceneDelegate,
|
||||
let optionalWindow = delegate.window,
|
||||
let window = optionalWindow
|
||||
else
|
||||
{
|
||||
return window
|
||||
authContinuation.resume(throwing: URLError(.badURL))
|
||||
}
|
||||
}
|
||||
|
||||
return UIWindow()
|
||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask)
|
||||
{
|
||||
Logger.main.debug("WKWebView stopped handling url scheme.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,38 +10,23 @@ import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct PatronResponse: Decodable
|
||||
{
|
||||
struct Attributes: Decodable
|
||||
typealias PatronResponse = DataResponse<PatronAttributes, PatronRelationships>
|
||||
|
||||
struct PatronAttributes: Decodable
|
||||
{
|
||||
var full_name: String?
|
||||
var patron_status: String?
|
||||
var currently_entitled_amount_cents: Int32 // In campaign's currency
|
||||
}
|
||||
|
||||
struct Relationships: Decodable
|
||||
struct PatronRelationships: Decodable
|
||||
{
|
||||
struct Tiers: Decodable
|
||||
{
|
||||
struct TierID: Decodable
|
||||
{
|
||||
var id: String
|
||||
var type: String
|
||||
}
|
||||
|
||||
var data: [TierID]
|
||||
}
|
||||
|
||||
var currently_entitled_tiers: Tiers
|
||||
}
|
||||
|
||||
var id: String
|
||||
var attributes: Attributes
|
||||
|
||||
var relationships: Relationships?
|
||||
var campaign: Response<AnyItemResponse>?
|
||||
var currently_entitled_tiers: Response<[AnyItemResponse]>?
|
||||
}
|
||||
}
|
||||
|
||||
extension Patron
|
||||
extension PatreonAPI
|
||||
{
|
||||
public enum Status: String, Decodable
|
||||
{
|
||||
@@ -50,21 +35,25 @@ extension Patron
|
||||
case former = "former_patron"
|
||||
case unknown = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
public class Patron
|
||||
{
|
||||
// Roughly equivalent to AltStoreCore.Pledge
|
||||
public class Patron
|
||||
{
|
||||
public var name: String?
|
||||
public var identifier: String
|
||||
|
||||
public var pledgeAmount: Decimal?
|
||||
public var status: Status
|
||||
|
||||
// Relationships
|
||||
public var campaign: Campaign?
|
||||
public var tiers: Set<Tier> = []
|
||||
public var benefits: Set<Benefit> = []
|
||||
|
||||
init(response: PatreonAPI.PatronResponse)
|
||||
internal init(response: PatronResponse, including included: IncludedResponses?)
|
||||
{
|
||||
self.name = response.attributes.full_name
|
||||
self.identifier = response.id
|
||||
self.pledgeAmount = Decimal(response.attributes.currently_entitled_amount_cents) / 100
|
||||
|
||||
if let status = response.attributes.patron_status
|
||||
{
|
||||
@@ -74,5 +63,20 @@ public class Patron
|
||||
{
|
||||
self.status = .unknown
|
||||
}
|
||||
|
||||
guard let included, let relationships = response.relationships else { return }
|
||||
|
||||
if let campaignID = relationships.campaign?.data.id, let response = included.campaigns[campaignID]
|
||||
{
|
||||
let campaign = Campaign(response: response)
|
||||
self.campaign = campaign
|
||||
}
|
||||
|
||||
let tiers = (relationships.currently_entitled_tiers?.data ?? []).compactMap { included.tiers[$0.id] }.map { Tier(response: $0, including: included) }
|
||||
self.tiers = Set(tiers)
|
||||
|
||||
let benefits = tiers.flatMap { $0.benefits }
|
||||
self.benefits = Set(benefits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,41 +10,43 @@ import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct TierResponse: Decodable
|
||||
{
|
||||
struct Attributes: Decodable
|
||||
typealias TierResponse = DataResponse<TierAttributes, TierRelationships>
|
||||
|
||||
struct TierAttributes: Decodable
|
||||
{
|
||||
var title: String
|
||||
var amount_cents: Int32 // In USD
|
||||
}
|
||||
|
||||
struct Relationships: Decodable
|
||||
struct TierRelationships: Decodable
|
||||
{
|
||||
struct Benefits: Decodable
|
||||
{
|
||||
var data: [BenefitResponse]
|
||||
}
|
||||
|
||||
var benefits: Benefits
|
||||
}
|
||||
|
||||
var id: String
|
||||
var attributes: Attributes
|
||||
|
||||
var relationships: Relationships
|
||||
var benefits: Response<[AnyItemResponse]>?
|
||||
}
|
||||
}
|
||||
|
||||
public struct Tier
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct Tier: Hashable
|
||||
{
|
||||
public var name: String
|
||||
public var identifier: String
|
||||
public var amount: Decimal
|
||||
|
||||
// Relationships
|
||||
public var benefits: [Benefit] = []
|
||||
|
||||
init(response: PatreonAPI.TierResponse)
|
||||
internal init(response: TierResponse, including included: IncludedResponses?)
|
||||
{
|
||||
self.name = response.attributes.title
|
||||
self.identifier = response.id
|
||||
self.benefits = response.relationships.benefits.data.map(Benefit.init(response:))
|
||||
|
||||
let amount = Decimal(response.attributes.amount_cents) / 100
|
||||
self.amount = amount
|
||||
|
||||
guard let included, let benefitIDs = response.relationships?.benefits?.data.map(\.id) else { return }
|
||||
|
||||
let benefits = benefitIDs.compactMap { included.benefits[$0] }.map(Benefit.init(response:))
|
||||
self.benefits = benefits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
AltStoreCore/Patreon/UserAccount.swift
Normal file
49
AltStoreCore/Patreon/UserAccount.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Account.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 11/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
typealias UserAccountResponse = DataResponse<UserAccountAttributes, AnyRelationships>
|
||||
|
||||
struct UserAccountAttributes: Decodable
|
||||
{
|
||||
var first_name: String?
|
||||
var full_name: String
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct UserAccount
|
||||
{
|
||||
var name: String
|
||||
var firstName: String?
|
||||
var identifier: String
|
||||
|
||||
// Relationships
|
||||
var pledges: [Patron]?
|
||||
|
||||
init(response: UserAccountResponse, including included: IncludedResponses?)
|
||||
{
|
||||
self.identifier = response.id
|
||||
self.name = response.attributes.full_name
|
||||
self.firstName = response.attributes.first_name
|
||||
|
||||
guard let included else { return }
|
||||
|
||||
let patrons = included.patrons.values.compactMap { response -> Patron? in
|
||||
let patron = Patron(response: response, including: included)
|
||||
return patron
|
||||
}
|
||||
|
||||
self.pledges = patrons
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public protocol AppProtocol
|
||||
var name: String { get }
|
||||
var bundleIdentifier: String { get }
|
||||
var url: URL? { get }
|
||||
|
||||
var storeApp: StoreApp? { get }
|
||||
}
|
||||
|
||||
public struct AnyApp: AppProtocol
|
||||
@@ -21,12 +23,14 @@ public struct AnyApp: AppProtocol
|
||||
public var name: String
|
||||
public var bundleIdentifier: String
|
||||
public var url: URL?
|
||||
public var storeApp: StoreApp?
|
||||
|
||||
public init(name: String, bundleIdentifier: String, url: URL?)
|
||||
public init(name: String, bundleIdentifier: String, url: URL?, storeApp: StoreApp?)
|
||||
{
|
||||
self.name = name
|
||||
self.bundleIdentifier = bundleIdentifier
|
||||
self.url = url
|
||||
self.storeApp = storeApp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +39,10 @@ extension ALTApplication: AppProtocol
|
||||
public var url: URL? {
|
||||
return self.fileURL
|
||||
}
|
||||
|
||||
public var storeApp: StoreApp? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreApp: AppProtocol
|
||||
@@ -42,6 +50,10 @@ extension StoreApp: AppProtocol
|
||||
public var url: URL? {
|
||||
return self.latestAvailableVersion?.downloadURL
|
||||
}
|
||||
|
||||
public var storeApp: StoreApp? {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
extension InstalledApp: AppProtocol
|
||||
@@ -64,4 +76,8 @@ extension AppVersion: AppProtocol
|
||||
public var url: URL? {
|
||||
return self.downloadURL
|
||||
}
|
||||
|
||||
public var storeApp: StoreApp? {
|
||||
return self.app
|
||||
}
|
||||
}
|
||||
|
||||
13
AltStoreCore/Types/ALTPatreonBenefitID.h
Normal file
13
AltStoreCore/Types/ALTPatreonBenefitID.h
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// ALTPatreonBenefitType.h
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/27/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
typedef NSString *ALTPatreonBenefitID NS_TYPED_EXTENSIBLE_ENUM;
|
||||
extern ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess;
|
||||
extern ALTPatreonBenefitID const ALTPatreonBenefitIDCredits;
|
||||
12
AltStoreCore/Types/ALTPatreonBenefitID.m
Normal file
12
AltStoreCore/Types/ALTPatreonBenefitID.m
Normal file
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// ALTPatreonBenefitType.m
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/27/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
#import "ALTPatreonBenefitID.h"
|
||||
|
||||
ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess = @"1186336";
|
||||
ALTPatreonBenefitID const ALTPatreonBenefitIDCredits = @"1186340";
|
||||
@@ -1,13 +0,0 @@
|
||||
//
|
||||
// ALTPatreonBenefitType.h
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/27/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
typedef NSString *ALTPatreonBenefitType NS_TYPED_EXTENSIBLE_ENUM;
|
||||
extern ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess;
|
||||
extern ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits;
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// ALTPatreonBenefitType.m
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/27/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
#import "ALTPatreonBenefitType.h"
|
||||
|
||||
ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess = @"1186336";
|
||||
ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits = @"1186340";
|
||||
@@ -10,3 +10,4 @@
|
||||
|
||||
typedef NSString *ALTSourceUserInfoKey NS_TYPED_EXTENSIBLE_ENUM;
|
||||
extern ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken;
|
||||
extern ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads;
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
#import "ALTSourceUserInfoKey.h"
|
||||
|
||||
ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken = @"patreonAccessToken";
|
||||
ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads = @"skipPatreonDownloads";
|
||||
|
||||
@@ -237,6 +237,8 @@ extension OperationError
|
||||
case .connectionFailed: return .connectionFailed
|
||||
case .connectionDropped: return .connectionDropped
|
||||
case .forbidden: return .forbidden()
|
||||
case .pledgeRequired: return .pledgeRequired(appName: "Delta")
|
||||
case .pledgeInactive: return .pledgeInactive(appName: "Delta")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user