Compare commits

...

164 Commits
beta2 ... 1.1.3

Author SHA1 Message Date
Riley Testut
c325b994d9 Updates app version to 1.1.3 2020-01-29 13:15:33 -08:00
Riley Testut
719cea97e8 Replaces frameworks with static libraries
As of iOS 13.3.1, apps installed with free developer accounts that contain embedded frameworks fail to launch. To work around this, we now link all dependencies via Cocoapods as static libraries.
2020-01-29 12:06:26 -08:00
Riley Testut
748ad8588d Updates app version to 1.1.2 2019-12-16 13:56:23 -08:00
Riley Testut
0a2a54240d Updates apps.json for AltStore 1.1.2 2019-12-16 13:52:24 -08:00
Riley Testut
9211aef6d1 Adds Clip to apps.json 2019-12-16 13:52:14 -08:00
Riley Testut
11a4e1a2a7 Fixes crash when signing in
ALTAnisetteData.timeZone was nil for some users after receiving it from AltServer, so there is now a default time zone value to ensure it’s never nil.
2019-12-16 12:27:09 -08:00
Riley Testut
222cae7ede Updates apps.json for AltStore 1.1.1 and Delta 1.1.1 2019-12-13 11:58:53 -08:00
Riley Testut
2f82d2218c [AltServer] Fixes erroneous “3 App Limit Reached” error 2019-12-11 13:05:12 -08:00
Riley Testut
ae5ba81138 Fixes increasing app size when refreshing
We now delete temporary directory + resigned .ipa before installation is complete to ensure AltStore doesn’t quit before we have the chance to.
2019-12-11 12:26:48 -08:00
Riley Testut
48dfe5b2da Updates version to 1.1.1 2019-12-11 11:22:33 -08:00
Riley Testut
be1ea160e5 [AltServer] Updates version to 1.1.2 2019-12-11 11:22:31 -08:00
Riley Testut
9fcee16466 Updates AltSign 2019-12-11 10:54:32 -08:00
Riley Testut
95a1399e31 Updates Patreon access code 2019-12-10 11:03:05 -08:00
Riley Testut
a4c8c2ed07 Removes app-specific password message on sign-in screen 2019-12-09 14:25:08 -08:00
Riley Testut
7ebe36cce8 [AltServer] Updates app version to 1.1.1 2019-11-28 12:20:17 -06:00
Riley Testut
f0f15e984e Updates AltSign 2019-11-28 12:19:54 -06:00
Riley Testut
93fe4f6c2e [AltServer] Replaces Mail’s bundleID in anisette data with Xcode’s 2019-11-28 12:13:28 -06:00
Riley Testut
0d8d9ecd3b Updates apps.json 2019-11-19 01:40:55 -08:00
Riley Testut
56e1e7df1a [AltServer] Fixes notarization errors
- Compresses AltPlugin.mailbundle into .zip to prevent it from being signed when exporting archive
- Signs Sparkle framework with run script
2019-11-19 01:40:43 -08:00
Riley Testut
7b9207ebe2 [AltServer] Adds Sparkle support 2019-11-18 15:42:10 -08:00
Riley Testut
691e08202d [AltStore] Uses GrandSlam Authentication
Retrieves anisette data from AltServer so we can authenticate with GSA.
2019-11-18 14:49:17 -08:00
Riley Testut
9535595df1 [AltServer] Installs/uninstalls Mail.app plug-in 2019-11-18 14:42:38 -08:00
Riley Testut
438fc7cfa0 [AltServer] Uses GrandSlam Authentication
Uses Mail.app plug-in to retrieve the computer’s anisette data, which is necessary for GSA.
2019-11-18 14:17:57 -08:00
Riley Testut
9a55ef7117 Updates apps.json for AltStore 1.1b2 2019-11-13 11:36:21 -08:00
Riley Testut
3ba1669e51 [AltServer] Adds STAGING flag to conditionally download Delta version 2019-11-13 11:35:37 -08:00
Riley Testut
2ceadeb908 Updates version to 1.1b2 2019-11-05 18:42:16 -08:00
Riley Testut
201839635b Updates default ALTDeviceID 2019-11-05 18:09:35 -08:00
Riley Testut
77a119f292 Fixes incorrect UpdateCollectionViewCell dimmed tint color 2019-11-05 18:08:58 -08:00
Riley Testut
1650951d53 Fixes AppViewController navigation bar appearing upon app becoming active 2019-11-05 18:08:11 -08:00
Riley Testut
a381565172 Fixes non-functional AppViewController download button 2019-11-05 18:06:52 -08:00
Riley Testut
e249bc564e [AltServer] Fixes dropping connection before client receives response 2019-11-05 18:05:32 -08:00
Riley Testut
6ab56ad6d1 Changes ALTTeamType.individual localizedDescription to “Developer” 2019-11-05 14:25:59 -08:00
Riley Testut
36e8f6dd94 [AltServer] Removes all free provisioning profiles when installing apps 2019-11-05 14:20:15 -08:00
Riley Testut
249848d978 Fixes endless loading when sideloading invalid app 2019-11-05 13:26:01 -08:00
Riley Testut
9738612194 Updates launch screen 2019-11-05 13:24:26 -08:00
Riley Testut
0afc87cad4 [AltServer] Fixes notifications not appearing on Catalina 2019-11-04 15:08:20 -08:00
Riley Testut
79f05b0a89 Improves loading time when fetching patron names 2019-11-04 13:46:06 -08:00
Riley Testut
b194b4b642 Fetches Patreon creator access token from AltStore source 2019-11-04 13:42:19 -08:00
Riley Testut
f10f519eab Adds STAGING flag to conditionally use staging endpoint 2019-11-04 13:38:54 -08:00
Riley Testut
991846bd64 Fixes tint colors not dimming when presenting alerts 2019-11-04 12:36:29 -08:00
Riley Testut
7485472095 Fixes hard-to-see sideloading activity indicator in dark mode 2019-11-04 11:17:28 -08:00
Riley Testut
aba9f67393 Updates apps.json for AltStore 1.1b 2019-10-28 14:24:59 -07:00
Riley Testut
6bd3e93bea Revert "Uses ephemeral session when signing in to Patreon"
This reverts commit c39e9945ca.

Reverted because users might not be able to complete the login flow after verifying their emails.
2019-10-28 13:40:49 -07:00
Riley Testut
839a6cc534 Updates AltStore + AltServer to 1.1 2019-10-28 13:24:49 -07:00
Riley Testut
b29faefdec Fixes crash when installing unsigned apps 2019-10-28 13:23:36 -07:00
Riley Testut
e785fc47ee Fixes issue where AltStore revokes its own certificate
Uses embedded certificate from AltServer if possible, but then falls back to asking user to refresh AltStore manually if the certificate used to install AltStore is revoked.
2019-10-28 13:16:55 -07:00
Riley Testut
1bde885b17 [AltServer] Embeds encrypted certificate in AltStore app bundle 2019-10-28 12:53:56 -07:00
Riley Testut
1fed0ba710 Merge branch 'feature/dark_mode' into develop 2019-10-28 12:17:48 -07:00
Riley Testut
6e6bc1ca64 Fixes incorrect “No Updates” cell dark mode appearance 2019-10-28 12:17:07 -07:00
Riley Testut
4013029c04 Adds support for dark mode 2019-10-24 13:04:30 -07:00
Riley Testut
6f58cb9579 Updates PillButton appearance 2019-10-23 14:20:01 -07:00
Riley Testut
6ea8503c3d Revises News tab + AppViewController UI 2019-10-23 14:19:32 -07:00
Riley Testut
aa52633491 Revises My Apps UI 2019-10-23 14:07:13 -07:00
Riley Testut
28d27c862f Revises Browse UI 2019-10-22 21:36:15 -07:00
Riley Testut
c39e9945ca Uses ephemeral session when signing in to Patreon 2019-10-17 14:52:42 -07:00
Riley Testut
3bb3fba017 Fixes Patreon login screen not appearing on iOS 13 2019-10-17 14:52:13 -07:00
Riley Testut
ac8c6567db Fixes prematurely cancelling authentication during interactive dismissal 2019-10-17 14:37:45 -07:00
Riley Testut
d3103c5513 Updates apps.json for Delta 1.1 2019-10-17 13:13:49 -07:00
Riley Testut
92fb428e47 Adds “Prevent AltStore Expiring” news item 2019-10-11 15:03:26 -07:00
Riley Testut
c8d9c2f863 [AltServer] Updates bundle version to 2 2019-10-03 15:29:08 -07:00
Riley Testut
e1d9aa1391 Updates apps.json 2019-10-03 15:28:53 -07:00
Riley Testut
d3623aa55e Merge branch 'master' of https://github.com/rileytestut/AltStore 2019-10-03 15:27:45 -07:00
Riley Testut
25ff5b566f Opts-out of dark mode (for now) 2019-10-03 15:27:38 -07:00
Riley Testut
bd792c3062 Add LICENSE 2019-10-03 15:17:50 -07:00
Riley Testut
c4c4f8cff7 Adds README 2019-10-03 14:53:37 -07:00
Riley Testut
878dc35c83 Fixes incorrect permissions popover size on iOS 13 2019-10-03 13:52:47 -07:00
Riley Testut
cb3489f69c Fixes incorrect AppViewController header view size on iOS 13 2019-10-03 13:32:06 -07:00
Riley Testut
f1d287294d Handles iOS 13 dismiss gesture when signing in 2019-10-03 13:17:46 -07:00
Riley Testut
d76543d045 Fixes incorrect modal presentation of TabBarController on iOS 13 2019-10-03 12:36:49 -07:00
Riley Testut
7342f6d4b4 Fixes crash on launch on iOS 13 2019-10-03 12:30:53 -07:00
Riley Testut
198e7c7caf Fixes incorrect error message for expired Patreon access tokens 2019-10-03 12:27:12 -07:00
Riley Testut
1d740500f7 Updates AltSign 2019-09-30 13:59:17 -07:00
Riley Testut
fb054c440b [AltKit] Sets macOS deployment target to 10.14 2019-09-30 13:58:50 -07:00
Riley Testut
8c7f554909 Updates AltStore + AltServer to 1.0.1 2019-09-28 03:12:38 -07:00
Riley Testut
2b0e629dd1 Removes apps.json from bundled resources 2019-09-28 03:11:57 -07:00
Riley Testut
7a1f402c5d Fixes Login screen on iPhone SE 2019-09-27 18:56:18 -07:00
Riley Testut
ab56ce6004 Updates Patreon creator access token 2019-09-27 18:49:38 -07:00
Riley Testut
53e948c0a9 Improves error thrown when Patreon creator access token expires 2019-09-27 18:49:31 -07:00
Riley Testut
b4f8ae00db Updates release date for Delta, Delta (beta), and Clip 2019-09-27 17:40:40 -07:00
Riley Testut
9e610ddb73 Adds support for sideloading .ipa’s via “Open in…” 2019-09-27 17:39:36 -07:00
Riley Testut
7fc822948c [AltServer] Displays warning about revoking certificates when using developer Apple ID 2019-09-27 14:29:23 -07:00
Riley Testut
2d279775fe Updates apps.json for AltStore preview 2019-09-27 14:11:08 -07:00
Riley Testut
820b1fb718 Updates version to 1.0 2019-09-25 12:44:48 -07:00
Riley Testut
f6a797975f Updates icon attributions 2019-09-25 12:44:23 -07:00
Riley Testut
2977b79dcb [AltServer] Adds missing files to project 2019-09-25 12:44:00 -07:00
Riley Testut
0ce078a675 Rewords Patreon section in Settings 2019-09-25 12:43:32 -07:00
Riley Testut
de74aed83e Replaces Patreon photo of me with better photo of me 2019-09-25 12:41:53 -07:00
Riley Testut
01e2f635f8 [AltServer] Updates version to 1.0 2019-09-25 01:23:34 -07:00
Riley Testut
7b3f78082e [AltServer] Presents info notification on first launch 2019-09-25 01:23:23 -07:00
Riley Testut
046b36f4c4 Replaces tab bar icons 2019-09-25 01:22:16 -07:00
Riley Testut
1504a277d5 Re-enables checking if Patreon account is a patron 2019-09-25 00:53:36 -07:00
Riley Testut
865e3778b8 Adds reminder to use app-specific password on Login screen 2019-09-24 15:34:35 -07:00
Riley Testut
4c9480e6de [AltServer] Adds app-specific password info to Login alert 2019-09-24 15:33:20 -07:00
Riley Testut
14b2a10b4e Fixes parsing Patreon responses with null patron_status 2019-09-24 14:11:49 -07:00
Riley Testut
caac63c93b Updates apps.json 2019-09-22 00:23:39 -07:00
Riley Testut
32b4611c1e [Both] Updates AltStore + AltServer to 0.4 2019-09-21 22:58:05 -07:00
Riley Testut
993fa3eebb Revises “How it works” wording (again) 2019-09-21 22:58:05 -07:00
Riley Testut
3195a3f65d Presents notification when AltStore is about to expire 2019-09-21 22:31:10 -07:00
Riley Testut
b60d693056 Adds sound to News and Update alerts 2019-09-21 22:30:01 -07:00
Riley Testut
3faed8cf5c Updates wording in PatreonViewController 2019-09-21 21:27:47 -07:00
Riley Testut
6c91db1dcd Presents reminder to open AltStore after first background refresh 2019-09-21 21:27:20 -07:00
Riley Testut
f506988296 Updates cached AltStore bundle when app has been updated 2019-09-21 16:35:08 -07:00
Riley Testut
883e8cfbed Opens Twitter links in Twitter app if installed 2019-09-21 13:57:18 -07:00
Riley Testut
997376938a [AltServer] Updates AltStore download URL 2019-09-19 22:25:07 -07:00
Riley Testut
f51e41efab Hides Settings Debug section behind swipe gesture 2019-09-19 22:20:10 -07:00
Riley Testut
1117c05349 [AltServer] Adds app icon + updated menu bar icon 2019-09-19 22:12:06 -07:00
Riley Testut
26f799de72 Replaces personal email with AltStore email 2019-09-19 15:35:38 -07:00
Riley Testut
9ea584c1fb Adds placeholder view to NewsViewController and BrowseViewController 2019-09-19 15:18:21 -07:00
Riley Testut
73c44c5e29 Supports deep linking to Patreon settings 2019-09-19 14:43:26 -07:00
Riley Testut
00a7886941 Updates version to 0.31 2019-09-19 12:19:32 -07:00
Riley Testut
c5b0072443 Changes app icon + primary tint color 2019-09-19 11:38:38 -07:00
Riley Testut
94a22da471 Disables URL caching when fetching Source 2019-09-19 11:27:38 -07:00
Riley Testut
8bfa5c6ff3 Updates AltStore source to use new storage backend 2019-09-17 11:51:53 -07:00
Riley Testut
3a190afa3b Updates Apps.json 2019-09-15 19:39:01 -07:00
Riley Testut
d03d7eae42 Updates version to 0.3 2019-09-14 13:45:49 -07:00
Riley Testut
cb25e44636 [AltServer] Updates version to 0.3 2019-09-14 13:45:41 -07:00
Riley Testut
405e894768 [AltServer] Updates deployment target to macOS 10.14.4 2019-09-14 13:45:21 -07:00
Riley Testut
f03ae815d7 Temporarily enables Patreon benefits for all Patreon accounts 2019-09-14 13:41:58 -07:00
Riley Testut
9f9710c31d Updates + migrates Core Data model to v2 2019-09-14 13:22:38 -07:00
Riley Testut
ad69b9989c Updates Roxas 2019-09-14 12:32:00 -07:00
Riley Testut
e6fc491f6a [AltServer] Shows alert when installing AltStore onto second device 2019-09-14 11:29:34 -07:00
Riley Testut
f5d29cd2c1 Updates pods 2019-09-14 11:00:58 -07:00
Riley Testut
f47212000b Updates app icon 2019-09-14 11:00:17 -07:00
Riley Testut
5c3b129c7f Adds News tab bar image 2019-09-14 10:59:39 -07:00
Riley Testut
8110c12272 Adds support for Delta Lite 2019-09-14 10:58:41 -07:00
Riley Testut
7536b09c4a [AltServer] Deletes downloaded AltStore.ipa after installing to device 2019-09-13 14:48:12 -07:00
Riley Testut
deff48f9c3 [AltServer] Prefer free/individual teams over organization teams 2019-09-13 14:26:21 -07:00
Riley Testut
07746174d4 [AltServer] Improves error handling when installing apps 2019-09-13 14:25:26 -07:00
Riley Testut
e3cf7b203c [AltServer] Fixes missing LaunchAtLogin.framework 2019-09-12 15:21:19 -07:00
Riley Testut
ee20ac9a03 Presents app page when tapping updates 2019-09-12 13:51:03 -07:00
Riley Testut
ff5e805b81 Adds “Send Feedback” option in settings 2019-09-12 13:47:55 -07:00
Riley Testut
6214f1044b Improves handling of non-patron Patreon accounts 2019-09-12 13:08:38 -07:00
Riley Testut
502a5488b0 Adds support for installing AltStore beta from AltStore 2019-09-12 13:04:15 -07:00
Riley Testut
e3bf6d6239 Improves error message when multiple apps fail to refresh 2019-09-12 13:00:08 -07:00
Riley Testut
e510e9d992 Fixes crash when displaying new updates 2019-09-12 12:49:19 -07:00
Riley Testut
f01e4ec753 Rewords Patreon text in SettingsViewController 2019-09-10 12:33:14 -07:00
Riley Testut
225bbbe7af Fixes sideloaded apps disappearing after unlinking Patreon 2019-09-10 12:32:48 -07:00
Riley Testut
839b0b95fc Authenticates before checking for AltServers
This means Auth flow is presented even when AltServer is not nearby.
2019-09-10 12:19:46 -07:00
Riley Testut
f6768b2d72 [AltServer] Deletes received .ipa after installing 2019-09-10 12:17:26 -07:00
Riley Testut
6955f57063 Adds serverID to Info.plist when resigning AltStore 2019-09-09 17:40:05 -07:00
Riley Testut
5b59ccc6a0 Fixes non-legible toast view in AuthenticationViewController 2019-09-08 14:38:32 -07:00
Riley Testut
936474cd1c Fixes main thread freeze when installing/refreshing apps 2019-09-08 14:24:18 -07:00
Riley Testut
2192a756b2 Changes app tint color to Red (from Green) 2019-09-08 14:21:58 -07:00
Riley Testut
c8336d6199 Fixes inability to select .ipa files from document browser 2019-09-07 15:37:46 -07:00
Riley Testut
8881ebb0f2 Displays countdown for unreleased apps 2019-09-07 15:37:08 -07:00
Riley Testut
939d7c5f35 Handles Patreon de-authentication gracefully 2019-09-07 15:35:12 -07:00
Riley Testut
cf3977e7f3 Revises Settings + Patreon UI (again)
- Changes background color to red
- Improves Patreon screen
- Adds credits + software licenses
2019-09-07 15:34:07 -07:00
Riley Testut
ab8d51c000 Revises Auth flow UI 2019-09-07 15:29:19 -07:00
Riley Testut
f5ea5a140a Presents Local Notifications when new NewsItem is fetched in background 2019-09-06 14:41:30 -07:00
Riley Testut
e6bfdfdaee Revises Patreon UI 2019-09-05 15:37:58 -07:00
Riley Testut
6635565a1c Revises Settings UI 2019-09-05 11:59:10 -07:00
Riley Testut
859f8a255c Adds support for isBackgroundRefreshEnabled setting 2019-09-05 11:57:16 -07:00
Riley Testut
88ab3f0c37 Fixes crash when signing in with paid Developer account 2019-09-04 12:22:05 -07:00
Riley Testut
66c9f547c1 [AltServer] Uses NSAlerts for installation errors 2019-09-04 12:08:27 -07:00
Riley Testut
a37d02d5d1 [AltServer] Adds option to Launch at Login 2019-09-04 11:58:28 -07:00
Riley Testut
0c1f469dfa Prioritizes AltServer that originally installed AltStore over others 2019-09-04 10:45:24 -07:00
Riley Testut
d03f963d9b Improves some ALTServerError descriptions 2019-09-03 21:59:54 -07:00
Riley Testut
22fcb940f2 Improves provisioning profile logging when installing apps 2019-09-03 21:59:31 -07:00
Riley Testut
82b4d28698 Removes unused code from BrowseViewController 2019-09-03 21:58:44 -07:00
Riley Testut
c2a8b59e36 Adds News tab 2019-09-03 21:58:07 -07:00
Riley Testut
eb5b1a616a [AltStore] Adds basic Patreon integration
- Lists beta versions of apps when signed in to Patreon
- Lists names of Patrons with the Credits benefit
2019-08-28 11:13:22 -07:00
Riley Testut
8df4c97a74 [AltStore] Limits background app refreshing to once every 6 hours 2019-08-28 11:08:04 -07:00
Riley Testut
d45f052f16 [AltStore] Fixes potential endless loading of remote images 2019-08-27 16:00:59 -07:00
Riley Testut
7d48b831ed [AltStore] Loads images remotely rather than including them in app bundle 2019-08-20 19:06:03 -05:00
891 changed files with 22710 additions and 2623 deletions

View File

@@ -14,6 +14,7 @@ public extension Bundle
{
public static let deviceID = "ALTDeviceID"
public static let serverID = "ALTServerID"
public static let certificateID = "ALTCertificateID"
public static let appGroups = "ALTAppGroups"
public static let urlTypes = "CFBundleURLTypes"
@@ -26,4 +27,9 @@ public extension Bundle
let infoPlistURL = self.bundleURL.appendingPathComponent("Info.plist")
return infoPlistURL
}
var certificateURL: URL {
let infoPlistURL = self.bundleURL.appendingPathComponent("ALTCertificate.p12")
return infoPlistURL
}
}

View File

@@ -13,19 +13,26 @@ extern NSErrorDomain const AltServerInstallationErrorDomain;
typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError)
{
ALTServerErrorUnknown,
ALTServerErrorConnectionFailed,
ALTServerErrorLostConnection,
ALTServerErrorUnknown = 0,
ALTServerErrorConnectionFailed = 1,
ALTServerErrorLostConnection = 2,
ALTServerErrorDeviceNotFound,
ALTServerErrorDeviceWriteFailed,
ALTServerErrorDeviceNotFound = 3,
ALTServerErrorDeviceWriteFailed = 4,
ALTServerErrorInvalidRequest,
ALTServerErrorInvalidResponse,
ALTServerErrorInvalidRequest = 5,
ALTServerErrorInvalidResponse = 6,
ALTServerErrorInvalidApp,
ALTServerErrorInstallationFailed,
ALTServerErrorMaximumFreeAppLimitReached,
ALTServerErrorInvalidApp = 7,
ALTServerErrorInstallationFailed = 8,
ALTServerErrorMaximumFreeAppLimitReached = 9,
ALTServerErrorUnsupportediOSVersion = 10,
ALTServerErrorUnknownRequest = 11,
ALTServerErrorUnknownResponse = 12,
ALTServerErrorInvalidAnisetteData = 13,
ALTServerErrorPluginNotFound = 14
};
NS_ASSUME_NONNULL_BEGIN

View File

@@ -39,10 +39,10 @@ NSErrorDomain const AltServerInstallationErrorDomain = @"com.rileytestut.AltServ
return NSLocalizedString(@"Lost connection to AltServer.", @"");
case ALTServerErrorDeviceNotFound:
return NSLocalizedString(@"AltServer could not locate this device.", @"");
return NSLocalizedString(@"AltServer could not find this device.", @"");
case ALTServerErrorDeviceWriteFailed:
return NSLocalizedString(@"Failed to write app data to phone.", @"");
return NSLocalizedString(@"Failed to write app data to device.", @"");
case ALTServerErrorInvalidRequest:
return NSLocalizedString(@"AltServer received an invalid request.", @"");
@@ -58,6 +58,21 @@ NSErrorDomain const AltServerInstallationErrorDomain = @"com.rileytestut.AltServ
case ALTServerErrorMaximumFreeAppLimitReached:
return NSLocalizedString(@"You have reached the limit of 3 apps per device.", @"");
case ALTServerErrorUnsupportediOSVersion:
return NSLocalizedString(@"Your device must be running iOS 12.2 or later to install AltStore.", @"");
case ALTServerErrorUnknownRequest:
return NSLocalizedString(@"AltServer does not support this request.", @"");
case ALTServerErrorUnknownResponse:
return NSLocalizedString(@"Received an unknown response from AltServer.", @"");
case ALTServerErrorInvalidAnisetteData:
return NSLocalizedString(@"Invalid anisette data.", @"");
case ALTServerErrorPluginNotFound:
return NSLocalizedString(@"Could not connect to Mail plug-in. Please make sure the plug-in is installed and Mail is running, then try again.", @"");
}
}

View File

@@ -7,22 +7,201 @@
//
import Foundation
import AltSign
public let ALTServerServiceType = "_altserver._tcp"
// Can only automatically conform ALTServerError.Code to Codable, not ALTServerError itself
extension ALTServerError.Code: Codable {}
protocol ServerMessage: Codable
protocol ServerMessageProtocol: Codable
{
var version: Int { get }
var identifier: String { get }
}
public struct PrepareAppRequest: ServerMessage
public enum ServerRequest: Decodable
{
case anisetteData(AnisetteDataRequest)
case prepareApp(PrepareAppRequest)
case beginInstallation(BeginInstallationRequest)
case unknown(identifier: String, version: Int)
var identifier: String {
switch self
{
case .anisetteData(let request): return request.identifier
case .prepareApp(let request): return request.identifier
case .beginInstallation(let request): return request.identifier
case .unknown(let identifier, _): return identifier
}
}
var version: Int {
switch self
{
case .anisetteData(let request): return request.version
case .prepareApp(let request): return request.version
case .beginInstallation(let request): return request.version
case .unknown(_, let version): return version
}
}
private enum CodingKeys: String, CodingKey
{
case identifier
case version
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let version = try container.decode(Int.self, forKey: .version)
let identifier = try container.decode(String.self, forKey: .identifier)
switch identifier
{
case "AnisetteDataRequest":
let request = try AnisetteDataRequest(from: decoder)
self = .anisetteData(request)
case "PrepareAppRequest":
let request = try PrepareAppRequest(from: decoder)
self = .prepareApp(request)
case "BeginInstallationRequest":
let request = try BeginInstallationRequest(from: decoder)
self = .beginInstallation(request)
default:
self = .unknown(identifier: identifier, version: version)
}
}
}
public enum ServerResponse: Decodable
{
case anisetteData(AnisetteDataResponse)
case installationProgress(InstallationProgressResponse)
case error(ErrorResponse)
case unknown(identifier: String, version: Int)
var identifier: String {
switch self
{
case .anisetteData(let response): return response.identifier
case .installationProgress(let response): return response.identifier
case .error(let response): return response.identifier
case .unknown(let identifier, _): return identifier
}
}
var version: Int {
switch self
{
case .anisetteData(let response): return response.version
case .installationProgress(let response): return response.version
case .error(let response): return response.version
case .unknown(_, let version): return version
}
}
private enum CodingKeys: String, CodingKey
{
case identifier
case version
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let version = try container.decode(Int.self, forKey: .version)
let identifier = try container.decode(String.self, forKey: .identifier)
switch identifier
{
case "AnisetteDataResponse":
let response = try AnisetteDataResponse(from: decoder)
self = .anisetteData(response)
case "InstallationProgressResponse":
let response = try InstallationProgressResponse(from: decoder)
self = .installationProgress(response)
case "ErrorResponse":
let response = try ErrorResponse(from: decoder)
self = .error(response)
default:
self = .unknown(identifier: identifier, version: version)
}
}
}
public struct AnisetteDataRequest: ServerMessageProtocol
{
public var version = 1
public var identifier = "PrepareApp"
public var identifier = "AnisetteDataRequest"
public init()
{
}
}
public struct AnisetteDataResponse: ServerMessageProtocol
{
public var version = 1
public var identifier = "AnisetteDataResponse"
public var anisetteData: ALTAnisetteData
private enum CodingKeys: String, CodingKey
{
case identifier
case version
case anisetteData
}
public init(anisetteData: ALTAnisetteData)
{
self.anisetteData = anisetteData
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
self.version = try container.decode(Int.self, forKey: .version)
self.identifier = try container.decode(String.self, forKey: .identifier)
let json = try container.decode([String: String].self, forKey: .anisetteData)
if let anisetteData = ALTAnisetteData(json: json)
{
self.anisetteData = anisetteData
}
else
{
throw DecodingError.dataCorruptedError(forKey: CodingKeys.anisetteData, in: container, debugDescription: "Couuld not parse anisette data from JSON")
}
}
public func encode(to encoder: Encoder) throws
{
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.version, forKey: .version)
try container.encode(self.identifier, forKey: .identifier)
let json = self.anisetteData.json()
try container.encode(json, forKey: .anisetteData)
}
}
public struct PrepareAppRequest: ServerMessageProtocol
{
public var version = 1
public var identifier = "PrepareAppRequest"
public var udid: String
public var contentSize: Int
@@ -34,37 +213,41 @@ public struct PrepareAppRequest: ServerMessage
}
}
public struct BeginInstallationRequest: ServerMessage
public struct BeginInstallationRequest: ServerMessageProtocol
{
public var version = 1
public var identifier = "BeginInstallation"
public var identifier = "BeginInstallationRequest"
public init()
{
}
}
public struct ServerResponse: ServerMessage
public struct ErrorResponse: ServerMessageProtocol
{
public var version = 1
public var identifier = "ServerResponse"
public var identifier = "ErrorResponse"
public var error: ALTServerError {
return ALTServerError(self.errorCode)
}
private var errorCode: ALTServerError.Code
public init(error: ALTServerError)
{
self.errorCode = error.code
}
}
public struct InstallationProgressResponse: ServerMessageProtocol
{
public var version = 1
public var identifier = "InstallationProgressResponse"
public var progress: Double
public var error: ALTServerError? {
get {
guard let code = self.errorCode else { return nil }
return ALTServerError(code)
}
set {
self.errorCode = newValue?.code
}
}
private var errorCode: ALTServerError.Code?
public init(progress: Double, error: ALTServerError?)
public init(progress: Double)
{
self.progress = progress
self.error = error
}
}

View File

@@ -0,0 +1,19 @@
//
// ALTPluginService.h
// AltPlugin
//
// Created by Riley Testut on 11/14/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface ALTPluginService : NSObject
+ (instancetype)sharedService;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,99 @@
//
// ALTPluginService.m
// AltPlugin
//
// Created by Riley Testut on 11/14/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import "ALTPluginService.h"
#import <dlfcn.h>
#import "ALTAnisetteData.h"
@import AppKit;
@interface AKAppleIDSession : NSObject
- (id)appleIDHeadersForRequest:(id)arg1;
@end
@interface AKDevice
+ (AKDevice *)currentDevice;
- (NSString *)uniqueDeviceIdentifier;
- (NSString *)serialNumber;
- (NSString *)serverFriendlyDescription;
@end
@interface ALTPluginService ()
@property (nonatomic, readonly) NSISO8601DateFormatter *dateFormatter;
@end
@implementation ALTPluginService
+ (instancetype)sharedService
{
static ALTPluginService *_service = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_service = [[self alloc] init];
});
return _service;
}
- (instancetype)init
{
self = [super init];
if (self)
{
_dateFormatter = [[NSISO8601DateFormatter alloc] init];
}
return self;
}
+ (void)initialize
{
[[ALTPluginService sharedService] start];
}
- (void)start
{
dlopen("/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit", RTLD_NOW);
[[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(receiveNotification:) name:@"com.rileytestut.AltServer.FetchAnisetteData" object:nil];
}
- (void)receiveNotification:(NSNotification *)notification
{
NSString *requestUUID = notification.userInfo[@"requestUUID"];
NSMutableURLRequest* req = [[NSMutableURLRequest alloc] initWithURL:[[NSURL alloc] initWithString:@"https://developerservices2.apple.com/services/QH65B2/listTeams.action?clientId=XABBG36SBA"]];
[req setHTTPMethod:@"POST"];
AKAppleIDSession *session = [[NSClassFromString(@"AKAppleIDSession") alloc] initWithIdentifier:@"com.apple.gs.xcode.auth"];
NSDictionary *headers = [session appleIDHeadersForRequest:req];
AKDevice *device = [NSClassFromString(@"AKDevice") currentDevice];
NSDate *date = [self.dateFormatter dateFromString:headers[@"X-Apple-I-Client-Time"]];
ALTAnisetteData *anisetteData = [[NSClassFromString(@"ALTAnisetteData") alloc] initWithMachineID:headers[@"X-Apple-I-MD-M"]
oneTimePassword:headers[@"X-Apple-I-MD"]
localUserID:headers[@"X-Apple-I-MD-LU"]
routingInfo:[headers[@"X-Apple-I-MD-RINFO"] longLongValue]
deviceUniqueIdentifier:device.uniqueDeviceIdentifier
deviceSerialNumber:device.serialNumber
deviceDescription:device.serverFriendlyDescription
date:date
locale:[NSLocale currentLocale]
timeZone:[NSTimeZone localTimeZone]];
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:anisetteData requiringSecureCoding:YES error:nil];
[[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"com.rileytestut.AltServer.AnisetteDataResponse" object:nil userInfo:@{@"requestUUID": requestUUID, @"anisetteData": data} deliverImmediately:YES];
}
@end

62
AltPlugin/Info.plist Normal file
View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2019 Riley Testut. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string>ALTPluginService</string>
<key>Supported10.14PluginCompatibilityUUIDs</key>
<array>
<string># UUIDs for versions from 10.12 to 99.99.99</string>
<string># For mail version 10.0 (3226) on OS X Version 10.12 (build 16A319)</string>
<string>36CCB8BB-2207-455E-89BC-B9D6E47ABB5B</string>
<string># For mail version 10.1 (3251) on OS X Version 10.12.1 (build 16B2553a)</string>
<string>9054AFD9-2607-489E-8E63-8B09A749BC61</string>
<string># For mail version 10.2 (3259) on OS X Version 10.12.2 (build 16D12b)</string>
<string>1CD3B36A-0E3B-4A26-8F7E-5BDF96AAC97E</string>
<string># For mail version 10.3 (3273) on OS X Version 10.12.4 (build 16G1036)</string>
<string>21560BD9-A3CC-482E-9B99-95B7BF61EDC1</string>
<string># For mail version 11.0 (3441.0.1) on OS X Version 10.13 (build 17A315i)</string>
<string>C86CD990-4660-4E36-8CDA-7454DEB2E199</string>
<string># For mail version 12.0 (3445.100.39) on OS X Version 10.14.1 (build 18B45d)</string>
<string>A4343FAF-AE18-40D0-8A16-DFAE481AF9C1</string>
<string># For mail version 13.0 (3594.4.2) on OS X Version 10.15 (build 19A558d)</string>
<string>6EEA38FB-1A0B-469B-BB35-4C2E0EEA9053</string>
</array>
<key>Supported10.15PluginCompatibilityUUIDs</key>
<array>
<string># UUIDs for versions from 10.12 to 99.99.99</string>
<string># For mail version 10.0 (3226) on OS X Version 10.12 (build 16A319)</string>
<string>36CCB8BB-2207-455E-89BC-B9D6E47ABB5B</string>
<string># For mail version 10.1 (3251) on OS X Version 10.12.1 (build 16B2553a)</string>
<string>9054AFD9-2607-489E-8E63-8B09A749BC61</string>
<string># For mail version 10.2 (3259) on OS X Version 10.12.2 (build 16D12b)</string>
<string>1CD3B36A-0E3B-4A26-8F7E-5BDF96AAC97E</string>
<string># For mail version 10.3 (3273) on OS X Version 10.12.4 (build 16G1036)</string>
<string>21560BD9-A3CC-482E-9B99-95B7BF61EDC1</string>
<string># For mail version 11.0 (3441.0.1) on OS X Version 10.13 (build 17A315i)</string>
<string>C86CD990-4660-4E36-8CDA-7454DEB2E199</string>
<string># For mail version 12.0 (3445.100.39) on OS X Version 10.14.1 (build 18B45d)</string>
<string>A4343FAF-AE18-40D0-8A16-DFAE481AF9C1</string>
<string># For mail version 13.0 (3594.4.2) on OS X Version 10.15 (build 19A558d)</string>
<string>6EEA38FB-1A0B-469B-BB35-4C2E0EEA9053</string>
</array>
</dict>
</plist>

Binary file not shown.

View File

@@ -0,0 +1,79 @@
//
// AnisetteDataManager.swift
// AltServer
//
// Created by Riley Testut on 11/16/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import AltKit
class AnisetteDataManager: NSObject
{
static let shared = AnisetteDataManager()
private var anisetteDataCompletionHandlers: [String: (Result<ALTAnisetteData, Error>) -> Void] = [:]
private var anisetteDataTimers: [String: Timer] = [:]
private override init()
{
super.init()
DistributedNotificationCenter.default().addObserver(self, selector: #selector(AnisetteDataManager.handleAnisetteDataResponse(_:)), name: Notification.Name("com.rileytestut.AltServer.AnisetteDataResponse"), object: nil)
}
func requestAnisetteData(_ completion: @escaping (Result<ALTAnisetteData, Error>) -> Void)
{
let requestUUID = UUID().uuidString
self.anisetteDataCompletionHandlers[requestUUID] = completion
let timer = Timer(timeInterval: 1.0, repeats: false) { (timer) in
self.finishRequest(forUUID: requestUUID, result: .failure(ALTServerError(.pluginNotFound)))
}
self.anisetteDataTimers[requestUUID] = timer
RunLoop.main.add(timer, forMode: .default)
DistributedNotificationCenter.default().postNotificationName(Notification.Name("com.rileytestut.AltServer.FetchAnisetteData"), object: nil, userInfo: ["requestUUID": requestUUID], options: .deliverImmediately)
}
}
private extension AnisetteDataManager
{
@objc func handleAnisetteDataResponse(_ notification: Notification)
{
guard let userInfo = notification.userInfo, let requestUUID = userInfo["requestUUID"] as? String else { return }
if
let archivedAnisetteData = userInfo["anisetteData"] as? Data,
let anisetteData = try? NSKeyedUnarchiver.unarchivedObject(ofClass: ALTAnisetteData.self, from: archivedAnisetteData)
{
if let range = anisetteData.deviceDescription.lowercased().range(of: "(com.apple.mail")
{
var adjustedDescription = anisetteData.deviceDescription[..<range.lowerBound]
adjustedDescription += "(com.apple.dt.Xcode/3594.4.19)>"
anisetteData.deviceDescription = String(adjustedDescription)
}
self.finishRequest(forUUID: requestUUID, result: .success(anisetteData))
}
else
{
self.finishRequest(forUUID: requestUUID, result: .failure(ALTServerError(.invalidAnisetteData)))
}
}
func finishRequest(forUUID requestUUID: String, result: Result<ALTAnisetteData, Error>)
{
let completionHandler = self.anisetteDataCompletionHandlers[requestUUID]
self.anisetteDataCompletionHandlers[requestUUID] = nil
let timer = self.anisetteDataTimers[requestUUID]
self.anisetteDataTimers[requestUUID] = nil
timer?.invalidate()
completionHandler?(result)
}
}

View File

@@ -11,6 +11,27 @@ import UserNotifications
import AltSign
import LaunchAtLogin
import STPrivilegedTask
enum PluginError: LocalizedError
{
case installationScriptNotFound
case failedToRun(Int)
case scriptError(String)
var errorDescription: String? {
switch self
{
case .installationScriptNotFound: return NSLocalizedString("The installation script could not be found.", comment: "")
case .failedToRun(let errorCode): return String(format: NSLocalizedString("The installation script could not be run. (%@)", comment: ""), NSNumber(value: errorCode))
case .scriptError(let output): return output
}
}
}
private let pluginURL = URL(fileURLWithPath: "/Library/Mail/Bundles/AltPlugin.mailbundle")
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
@@ -22,10 +43,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@IBOutlet private var appMenu: NSMenu!
@IBOutlet private var connectedDevicesMenu: NSMenu!
@IBOutlet private var launchAtLoginMenuItem: NSMenuItem!
@IBOutlet private var installMailPluginMenuItem: NSMenuItem!
private weak var authenticationAppleIDTextField: NSTextField?
private weak var authenticationPasswordTextField: NSSecureTextField?
private var isMailPluginInstalled: Bool {
let isMailPluginInstalled = FileManager.default.fileExists(atPath: pluginURL.path)
return isMailPluginInstalled
}
func applicationDidFinishLaunching(_ aNotification: Notification)
{
UserDefaults.standard.registerDefaults()
@@ -43,6 +71,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.statusItem = item
self.connectedDevicesMenu.delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { (success, error) in
guard success else { return }
if !UserDefaults.standard.didPresentInitialNotification
{
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("AltServer Running", comment: "")
content.body = NSLocalizedString("AltServer runs in the background as a menu bar app listening for AltStore.", comment: "")
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
UserDefaults.standard.didPresentInitialNotification = true
}
}
}
func applicationWillTerminate(_ aNotification: Notification)
@@ -59,6 +103,21 @@ private extension AppDelegate
self.connectedDevices = ALTDeviceManager.shared.connectedDevices
self.launchAtLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off
self.launchAtLoginMenuItem.action = #selector(AppDelegate.toggleLaunchAtLogin(_:))
if FileManager.default.fileExists(atPath: pluginURL.path)
{
self.installMailPluginMenuItem.title = NSLocalizedString("Uninstall Mail Plug-in", comment: "")
}
else
{
self.installMailPluginMenuItem.title = NSLocalizedString("Install Mail Plug-in", comment: "")
}
self.installMailPluginMenuItem.target = self
self.installMailPluginMenuItem.action = #selector(AppDelegate.handleInstallMailPluginMenuItem(_:))
let x = button.frame.origin.x
let y = button.frame.origin.y - 5
@@ -120,22 +179,129 @@ private extension AppDelegate
let password = passwordTextField.stringValue
let device = self.connectedDevices[index]
if !self.isMailPluginInstalled
{
let result = self.installMailPlugin()
guard result else { return }
}
ALTDeviceManager.shared.installAltStore(to: device, appleID: username, password: password) { (result) in
let content = UNMutableNotificationContent()
switch result
{
case .success:
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("Installation Succeeded", comment: "")
content.body = String(format: NSLocalizedString("AltStore was successfully installed on %@.", comment: ""), device.name)
case .failure(let error):
content.title = NSLocalizedString("Installation Failed", comment: "")
content.body = error.localizedDescription
}
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
case .failure(InstallError.cancelled), .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
// Ignore
break
case .failure(let error as NSError):
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = NSLocalizedString("Installation Failed", comment: "")
if let underlyingError = error.userInfo[NSUnderlyingErrorKey] as? Error
{
alert.informativeText = underlyingError.localizedDescription
}
else
{
alert.informativeText = error.localizedDescription
}
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
alert.runModal()
}
}
}
@objc func toggleLaunchAtLogin(_ item: NSMenuItem)
{
if item.state == .on
{
item.state = .off
}
else
{
item.state = .on
}
LaunchAtLogin.isEnabled.toggle()
}
@objc func handleInstallMailPluginMenuItem(_ item: NSMenuItem)
{
installMailPlugin()
}
@discardableResult
func installMailPlugin() -> Bool
{
do
{
let previouslyInstalled = self.isMailPluginInstalled
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
if !previouslyInstalled
{
let alert = NSAlert()
alert.messageText = NSLocalizedString("Install Mail Plug-in", comment: "")
alert.informativeText = NSLocalizedString("AltServer requires a Mail plug-in in order to retrieve necessary information about your Apple ID. Would you like to install it now?", comment: "")
alert.addButton(withTitle: NSLocalizedString("Install Plug-in", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return false }
}
guard let scriptURL = Bundle.main.url(forResource: self.isMailPluginInstalled ? "UninstallPlugin" : "InstallPlugin", withExtension: "sh") else { throw PluginError.installationScriptNotFound }
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: scriptURL.path)
let task = STPrivilegedTask()
task.setLaunchPath(scriptURL.path)
task.setCurrentDirectoryPath(scriptURL.deletingLastPathComponent().path)
let errorCode = task.launch()
guard errorCode == 0 else { throw PluginError.failedToRun(Int(errorCode)) }
task.waitUntilExit()
if
let outputData = task.outputFileHandle()?.readDataToEndOfFile(),
let outputString = String(data: outputData, encoding: .utf8), !outputString.isEmpty
{
throw PluginError.scriptError(outputString)
}
if !previouslyInstalled && self.isMailPluginInstalled
{
let alert = NSAlert()
alert.messageText = NSLocalizedString("Mail Plug-in Installed", comment: "")
alert.informativeText = NSLocalizedString("Please restart Mail and enable AltPlugin in Mail's Preferences. Mail must be running when installing or refreshing apps with AltServer.", comment: "")
alert.runModal()
}
return true
}
catch
{
let alert = NSAlert()
alert.messageText = self.isMailPluginInstalled ? NSLocalizedString("Failed to Uninstall Mail Plug-in", comment: "") : NSLocalizedString("Failed to Install Mail Plug-in", comment: "")
alert.informativeText = error.localizedDescription
alert.runModal()
return false
}
}
}

View File

@@ -1,53 +1,63 @@
{
"images" : [
{
"idiom" : "mac",
"size" : "16x16",
"idiom" : "mac",
"filename" : "Icon@16.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "16x16",
"idiom" : "mac",
"filename" : "Icon@32-1.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "32x32",
"idiom" : "mac",
"filename" : "Icon@32.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "32x32",
"idiom" : "mac",
"filename" : "Icon@64.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "128x128",
"idiom" : "mac",
"filename" : "Icon@128.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "128x128",
"idiom" : "mac",
"filename" : "Icon@256-1.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "256x256",
"idiom" : "mac",
"filename" : "Icon@256.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "256x256",
"idiom" : "mac",
"filename" : "Icon@512-1.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "512x512",
"idiom" : "mac",
"filename" : "Icon@512.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "512x512",
"idiom" : "mac",
"filename" : "Icon@1024.png",
"scale" : "2x"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -2,12 +2,12 @@
"images" : [
{
"idiom" : "universal",
"filename" : "MenuBarIcon.png",
"filename" : "MenuBar@19.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "MenuBarIcon@2x.png",
"filename" : "MenuBar@38.png",
"scale" : "2x"
},
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="15504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="15504"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
@@ -10,11 +11,11 @@
<objects>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<stackView distribution="fill" orientation="vertical" alignment="leading" spacing="4" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" id="urc-xw-Dhc">
<rect key="frame" x="0.0" y="0.0" width="300" height="48"/>
<rect key="frame" x="0.0" y="0.0" width="300" height="46"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="zLd-d8-ghZ">
<rect key="frame" x="0.0" y="26" width="300" height="22"/>
<rect key="frame" x="0.0" y="25" width="300" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Apple ID" drawsBackground="YES" id="BXa-Re-rs3">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
@@ -26,7 +27,7 @@
</connections>
</textField>
<secureTextField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9rp-Vx-rvB">
<rect key="frame" x="0.0" y="0.0" width="300" height="22"/>
<rect key="frame" x="0.0" y="0.0" width="300" height="21"/>
<secureTextFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Password" drawsBackground="YES" usesSingleLineMode="YES" id="xqJ-wt-DlP">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
@@ -61,8 +62,11 @@
<outlet property="authenticationAppleIDTextField" destination="zLd-d8-ghZ" id="wW5-0J-zdq"/>
<outlet property="authenticationPasswordTextField" destination="9rp-Vx-rvB" id="ZoC-DI-jzQ"/>
<outlet property="connectedDevicesMenu" destination="KJ9-WY-pW1" id="Mcv-64-iFU"/>
<outlet property="installMailPluginMenuItem" destination="3CM-gV-X2G" id="lio-ha-z0S"/>
<outlet property="launchAtLoginMenuItem" destination="IyR-FQ-upe" id="Fxn-EP-hwH"/>
</connections>
</customObject>
<customObject id="Arf-IC-5eb" customClass="SUUpdater"/>
<application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
@@ -93,7 +97,21 @@
</connections>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="1ZZ-BB-xHy"/>
<menuItem title="Launch at Login" id="IyR-FQ-upe" userLabel="Launch At Login">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem title="Install Mail Plug-in" id="3CM-gV-X2G" userLabel="Mail Plug-in">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="mVM-Nm-Zi9"/>
<menuItem title="Check for Updates..." id="Tnq-gD-Eic" userLabel="Check for Updates">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="checkForUpdates:" target="Arf-IC-5eb" id="7JG-du-nr4"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="hmG-xg-qgm"/>
<menuItem title="Quit AltServer" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>

View File

@@ -8,6 +8,7 @@
import Foundation
import Network
import AppKit
import AltKit
@@ -188,7 +189,6 @@ private extension ConnectionManager
guard !self.connections.contains(where: { $0 === connection }) else { return }
self.connections.append(connection)
connection.stateUpdateHandler = { [weak self] (state) in
switch state
{
@@ -196,10 +196,7 @@ private extension ConnectionManager
case .ready:
print("Connected to client:", connection.endpoint)
self?.receiveApp(from: connection) { (result) in
self?.finish(connection: connection, error: result.error)
}
self?.handleRequest(for: connection)
case .waiting:
print("Waiting for connection...")
@@ -218,70 +215,127 @@ private extension ConnectionManager
connection.start(queue: self.dispatchQueue)
}
func receiveApp(from connection: NWConnection, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
func handleRequest(for connection: NWConnection)
{
self.receive(PrepareAppRequest.self, from: connection) { (result) in
print("Received request with result:", result)
self.receiveRequest(from: connection) { (result) in
print("Received initial request with result:", result)
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let request):
self.receiveApp(for: request, from: connection) { (result) in
print("Received app with result:", result)
case .failure(let error):
let response = ErrorResponse(error: ALTServerError(error))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent error response with result:", result)
}
case .success(.anisetteData(let request)):
self.handleAnisetteDataRequest(request, for: connection)
case .success(.prepareApp(let request)):
self.handlePrepareAppRequest(request, for: connection)
case .success:
let response = ErrorResponse(error: ALTServerError(.unknownRequest))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent unknown request response with result:", result)
}
}
}
}
func handleAnisetteDataRequest(_ request: AnisetteDataRequest, for connection: NWConnection)
{
AnisetteDataManager.shared.requestAnisetteData { (result) in
switch result
{
case .failure(let error):
let errorResponse = ErrorResponse(error: ALTServerError(error))
self.send(errorResponse, to: connection, shouldDisconnect: true) { (result) in
print("Sent anisette data error response with result:", result)
}
case .success(let anisetteData):
let response = AnisetteDataResponse(anisetteData: anisetteData)
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent anisette data response with result:", result)
}
}
}
}
func handlePrepareAppRequest(_ request: PrepareAppRequest, for connection: NWConnection)
{
var temporaryURL: URL?
func finish(_ result: Result<Void, ALTServerError>)
{
if let temporaryURL = temporaryURL
{
do { try FileManager.default.removeItem(at: temporaryURL) }
catch { print("Failed to remove .ipa.", error) }
}
switch result
{
case .failure(let error):
print("Failed to process request from \(connection.endpoint).", error)
let response = ErrorResponse(error: ALTServerError(error))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent install app error response to \(connection.endpoint) with result:", result)
}
case .success:
print("Processed request from \(connection.endpoint).")
let response = InstallationProgressResponse(progress: 1.0)
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent install app response to \(connection.endpoint) with result:", result)
}
}
}
self.receiveApp(for: request, from: connection) { (result) in
print("Received app with result:", result)
switch result
{
case .failure(let error): finish(.failure(error))
case .success(let fileURL):
temporaryURL = fileURL
print("Awaiting begin installation request...")
self.receiveRequest(from: connection) { (result) in
print("Received begin installation request with result:", result)
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let request, let fileURL):
print("Awaiting begin installation request...")
case .failure(let error): finish(.failure(error))
case .success(.beginInstallation):
print("Installing to device \(request.udid)...")
self.receive(BeginInstallationRequest.self, from: connection) { (result) in
print("Received begin installation request with result:", result)
self.installApp(at: fileURL, toDeviceWithUDID: request.udid, connection: connection) { (result) in
print("Installed to device with result:", result)
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success:
print("Installing to device \(request.udid)...")
self.installApp(at: fileURL, toDeviceWithUDID: request.udid, connection: connection) { (result) in
print("Installed to device with result:", result)
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success: completionHandler(.success(()))
}
}
case .failure(let error): finish(.failure(error))
case .success: finish(.success(()))
}
}
case .success:
let response = ErrorResponse(error: ALTServerError(.unknownRequest))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent unknown request error response to \(connection.endpoint) with result:", result)
}
}
}
}
}
}
func finish(connection: NWConnection, error: ALTServerError?)
{
if let error = error
{
print("Failed to process request from \(connection.endpoint).", error)
}
else
{
print("Processed request from \(connection.endpoint).")
}
let response = ServerResponse(progress: 1.0, error: error)
self.send(response, to: connection) { (result) in
print("Sent response to \(connection.endpoint) with result:", result)
self.disconnect(connection)
}
}
func receiveApp(for request: PrepareAppRequest, from connection: NWConnection, completionHandler: @escaping (Result<(PrepareAppRequest, URL), ALTServerError>) -> Void)
func receiveApp(for request: PrepareAppRequest, from connection: NWConnection, completionHandler: @escaping (Result<URL, ALTServerError>) -> Void)
{
connection.receive(minimumIncompleteLength: request.contentSize, maximumLength: request.contentSize) { (data, _, _, error) in
do
@@ -301,7 +355,7 @@ private extension ConnectionManager
print("Wrote app to URL:", temporaryURL)
completionHandler(.success((request, temporaryURL)))
completionHandler(.success(temporaryURL))
}
catch
{
@@ -341,7 +395,7 @@ private extension ConnectionManager
isSending = true
print("Progress:", progress.fractionCompleted)
let response = ServerResponse(progress: progress.fractionCompleted, error: nil)
let response = InstallationProgressResponse(progress: progress.fractionCompleted)
self.send(response, to: connection) { (result) in
serialQueue.async {
@@ -352,8 +406,21 @@ private extension ConnectionManager
})
}
func send<T: Encodable>(_ response: T, to connection: NWConnection, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
func send<T: Encodable>(_ response: T, to connection: NWConnection, shouldDisconnect: Bool = false, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
{
func finish(_ result: Result<Void, ALTServerError>)
{
completionHandler(result)
if shouldDisconnect
{
// Add short delay to prevent us from dropping connection too quickly.
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
self.disconnect(connection)
}
}
}
do
{
let data = try JSONEncoder().encode(response)
@@ -370,27 +437,27 @@ private extension ConnectionManager
connection.send(content: data, completion: .contentProcessed { (error) in
if error != nil
{
completionHandler(.failure(.init(.lostConnection)))
finish(.failure(.init(.lostConnection)))
}
else
{
completionHandler(.success(()))
finish(.success(()))
}
})
}
catch
{
completionHandler(.failure(.init(.lostConnection)))
finish(.failure(.init(.lostConnection)))
}
})
}
catch
{
completionHandler(.failure(.init(.invalidResponse)))
finish(.failure(.init(.invalidResponse)))
}
}
func receive<T: Decodable>(_ responseType: T.Type, from connection: NWConnection, completionHandler: @escaping (Result<T, ALTServerError>) -> Void)
func receiveRequest(from connection: NWConnection, completionHandler: @escaping (Result<ServerRequest, ALTServerError>) -> Void)
{
let size = MemoryLayout<Int32>.size
@@ -408,7 +475,7 @@ private extension ConnectionManager
{
let data = try self.process(data: data, error: error, from: connection)
let request = try JSONDecoder().decode(T.self, from: data)
let request = try JSONDecoder().decode(ServerRequest.self, from: data)
print("Received installation request:", request)

View File

@@ -8,18 +8,25 @@
import Cocoa
import UserNotifications
import ObjectiveC
enum InstallError: Error
#if STAGING
private let appURL = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/altstore.ipa")!
#else
private let appURL = URL(string: "https://f000.backblazeb2.com/file/altstore/altstore.ipa")!
#endif
enum InstallError: LocalizedError
{
case invalidCredentials
case cancelled
case noTeam
case missingPrivateKey
case missingCertificate
var localizedDescription: String {
var errorDescription: String? {
switch self
{
case .invalidCredentials: return "The provided Apple ID and password are incorrect."
case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "")
case .noTeam: return "You are not a member of any developer teams."
case .missingPrivateKey: return "The developer certificate's private key could not be found."
case .missingCertificate: return "The developer certificate could not be found."
@@ -49,116 +56,135 @@ extension ALTDeviceManager
try? FileManager.default.removeItem(at: destinationDirectoryURL)
}
self.authenticate(appleID: appleID, password: password) { (result) in
AnisetteDataManager.shared.requestAnisetteData { (result) in
do
{
let account = try result.get()
let anisetteData = try result.get()
let content = UNMutableNotificationContent()
content.title = String(format: NSLocalizedString("Installing AltStore to %@...", comment: ""), device.name)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
self.fetchTeam(for: account) { (result) in
self.authenticate(appleID: appleID, password: password, anisetteData: anisetteData) { (result) in
do
{
let team = try result.get()
let (account, session) = try result.get()
self.register(device, team: team) { (result) in
self.fetchTeam(for: account, session: session) { (result) in
do
{
let device = try result.get()
let team = try result.get()
self.fetchCertificate(for: team) { (result) in
self.register(device, team: team, session: session) { (result) in
do
{
let certificate = try result.get()
let device = try result.get()
self.downloadApp { (result) in
self.fetchCertificate(for: team, session: session) { (result) in
do
{
let fileURL = try result.get()
let certificate = try result.get()
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
let content = UNMutableNotificationContent()
content.title = String(format: NSLocalizedString("Installing AltStore to %@...", comment: ""), device.name)
content.body = NSLocalizedString("This may take a few seconds.", comment: "")
let appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: destinationDirectoryURL)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
guard let application = ALTApplication(fileURL: appBundleURL) else { throw ALTError(.invalidApp) }
self.registerAppID(name: "AltStore", identifier: "com.rileytestut.AltStore", team: team) { (result) in
self.downloadApp { (result) in
do
{
let appID = try result.get()
let fileURL = try result.get()
self.updateFeatures(for: appID, app: application, team: team) { (result) in
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
let appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: destinationDirectoryURL)
do
{
try FileManager.default.removeItem(at: fileURL)
}
catch
{
print("Failed to remove downloaded .ipa.", error)
}
guard let application = ALTApplication(fileURL: appBundleURL) else { throw ALTError(.invalidApp) }
self.registerAppID(name: "AltStore", identifier: "com.rileytestut.AltStore", team: team, session: session) { (result) in
do
{
let appID = try result.get()
self.fetchProvisioningProfile(for: appID, team: team) { (result) in
self.updateFeatures(for: appID, app: application, team: team, session: session) { (result) in
do
{
let provisioningProfile = try result.get()
let appID = try result.get()
self.install(application, to: device, team: team, appID: appID, certificate: certificate, profile: provisioningProfile) { (result) in
finish(result.error, title: "Failed to Install AltStore")
self.fetchProvisioningProfile(for: appID, team: team, session: session) { (result) in
do
{
let provisioningProfile = try result.get()
self.install(application, to: device, team: team, appID: appID, certificate: certificate, profile: provisioningProfile) { (result) in
finish(result.error, title: "Failed to Install AltStore")
}
}
catch
{
finish(error, title: "Failed to Fetch Provisioning Profile")
}
}
}
catch
{
finish(error, title: "Failed to Fetch Provisioning Profile")
finish(error, title: "Failed to Update App ID")
}
}
}
catch
{
finish(error, title: "Failed to Update App ID")
finish(error, title: "Failed to Register App")
}
}
}
catch
{
finish(error, title: "Failed to Register App")
finish(error, title: "Failed to Download AltStore")
return
}
}
}
catch
{
finish(error, title: "Failed to Download AltStore")
return
finish(error, title: "Failed to Fetch Certificate")
}
}
}
catch
{
finish(error, title: "Failed to Fetch Certificate")
finish(error, title: "Failed to Register Device")
}
}
}
catch
{
finish(error, title: "Failed to Register Device")
finish(error, title: "Failed to Fetch Team")
}
}
}
catch
{
finish(error, title: "Failed to Fetch Team")
finish(error, title: "Failed to Authenticate")
}
}
}
catch
{
finish(error, title: "Failed to Authenticate")
finish(error, title: "Failed to Fetch Anisette Data")
}
}
}
func downloadApp(completionHandler: @escaping (Result<URL, Error>) -> Void)
{
let appURL = URL(string: "https://www.dropbox.com/s/w1gn9iztlqvltyp/AltStore.ipa?dl=1")!
let downloadTask = URLSession.shared.downloadTask(with: appURL) { (fileURL, response, error) in
do
{
@@ -174,45 +200,173 @@ extension ALTDeviceManager
downloadTask.resume()
}
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<ALTAccount, Error>) -> Void)
func authenticate(appleID: String, password: String, anisetteData: ALTAnisetteData, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void)
{
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password) { (account, error) in
let result = Result(account, error)
completionHandler(result)
func handleVerificationCode(_ completionHandler: @escaping (String?) -> Void)
{
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Two-Factor Authentication Enabled", comment: "")
alert.informativeText = NSLocalizedString("Please enter the 6-digit verification code that was sent to your Apple devices.", comment: "")
let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 22))
textField.delegate = self
textField.translatesAutoresizingMaskIntoConstraints = false
textField.placeholderString = NSLocalizedString("123456", comment: "")
alert.accessoryView = textField
alert.window.initialFirstResponder = textField
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
self.securityCodeAlert = alert
self.securityCodeTextField = textField
self.validate()
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let response = alert.runModal()
if response == .alertFirstButtonReturn
{
let code = textField.stringValue
completionHandler(code)
}
else
{
completionHandler(nil)
}
}
}
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: anisetteData, verificationHandler: handleVerificationCode) { (account, session, error) in
if let account = account, let session = session
{
completionHandler(.success((account, session)))
}
else
{
completionHandler(.failure(error ?? ALTAppleAPIError(.unknown)))
}
}
}
func fetchTeam(for account: ALTAccount, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
{
ALTAppleAPI.shared.fetchTeams(for: account) { (teams, error) in
do
func finish(_ result: Result<ALTTeam, Error>)
{
switch result
{
let teams = try Result(teams, error).get()
guard let team = teams.first else { throw InstallError.noTeam }
case .failure(let error):
completionHandler(.failure(error))
case .success(let team):
var isCancelled = false
if team.type != .free
{
DispatchQueue.main.sync {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Installing AltStore will revoke your iOS development certificate.", comment: "")
alert.informativeText = NSLocalizedString("""
This will not affect apps you've submitted to the App Store, but may cause apps you've installed to your devices with Xcode to stop working until you reinstall them.
To prevent this from happening, feel free to try again with another Apple ID to install AltStore.
""", comment: "")
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let buttonIndex = alert.runModal()
if buttonIndex == NSApplication.ModalResponse.alertSecondButtonReturn
{
isCancelled = true
}
}
if isCancelled
{
return completionHandler(.failure(InstallError.cancelled))
}
}
completionHandler(.success(team))
}
}
ALTAppleAPI.shared.fetchTeams(for: account, session: session) { (teams, error) in
do
{
let teams = try Result(teams, error).get()
if let team = teams.first(where: { $0.type == .free })
{
return finish(.success(team))
}
else if let team = teams.first(where: { $0.type == .individual })
{
return finish(.success(team))
}
else if let team = teams.first
{
return finish(.success(team))
}
else
{
throw InstallError.noTeam
}
}
catch
{
completionHandler(.failure(error))
finish(.failure(error))
}
}
}
func fetchCertificate(for team: ALTTeam, completionHandler: @escaping (Result<ALTCertificate, Error>) -> Void)
func fetchCertificate(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTCertificate, Error>) -> Void)
{
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
let certificates = try Result(certificates, error).get()
// Check if there is another AltStore certificate, which means AltStore has been installed with this Apple ID before.
if certificates.contains(where: { $0.machineName?.starts(with: "AltStore") == true })
{
var isCancelled = false
DispatchQueue.main.sync {
let alert = NSAlert()
alert.messageText = NSLocalizedString("AltStore already installed on another device.", comment: "")
alert.informativeText = NSLocalizedString("Apps installed with AltStore on your other devices will stop working. Are you sure you want to continue?", comment: "")
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let buttonIndex = alert.runModal()
if buttonIndex == NSApplication.ModalResponse.alertSecondButtonReturn
{
isCancelled = true
}
}
if isCancelled
{
return completionHandler(.failure(InstallError.cancelled))
}
}
if let certificate = certificates.first
{
ALTAppleAPI.shared.revoke(certificate, for: team) { (success, error) in
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in
do
{
try Result(success, error).get()
self.fetchCertificate(for: team, completionHandler: completionHandler)
self.fetchCertificate(for: team, session: session, completionHandler: completionHandler)
}
catch
{
@@ -222,13 +376,13 @@ extension ALTDeviceManager
}
else
{
ALTAppleAPI.shared.addCertificate(machineName: "AltStore", to: team) { (certificate, error) in
ALTAppleAPI.shared.addCertificate(machineName: "AltStore", to: team, session: session) { (certificate, error) in
do
{
let certificate = try Result(certificate, error).get()
guard let privateKey = certificate.privateKey else { throw InstallError.missingPrivateKey }
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
let certificates = try Result(certificates, error).get()
@@ -261,11 +415,11 @@ extension ALTDeviceManager
}
}
func registerAppID(name appName: String, identifier: String, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
func registerAppID(name appName: String, identifier: String, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
let bundleID = "com.\(team.identifier).\(identifier)"
ALTAppleAPI.shared.fetchAppIDs(for: team) { (appIDs, error) in
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { (appIDs, error) in
do
{
let appIDs = try Result(appIDs, error).get()
@@ -276,7 +430,7 @@ extension ALTDeviceManager
}
else
{
ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team) { (appID, error) in
ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team, session: session) { (appID, error) in
completionHandler(Result(appID, error))
}
}
@@ -288,10 +442,10 @@ extension ALTDeviceManager
}
}
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
let requiredFeatures = app.entitlements.compactMap { (entitlement, value) -> (ALTFeature, Any)? in
guard let feature = ALTFeature(entitlement) else { return nil }
guard let feature = ALTFeature(entitlement: entitlement) else { return nil }
return (feature, value)
}
@@ -305,14 +459,14 @@ extension ALTDeviceManager
let appID = appID.copy() as! ALTAppID
appID.features = features
ALTAppleAPI.shared.update(appID, team: team) { (appID, error) in
ALTAppleAPI.shared.update(appID, team: team, session: session) { (appID, error) in
completionHandler(Result(appID, error))
}
}
func register(_ device: ALTDevice, team: ALTTeam, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
func register(_ device: ALTDevice, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
{
ALTAppleAPI.shared.fetchDevices(for: team) { (devices, error) in
ALTAppleAPI.shared.fetchDevices(for: team, session: session) { (devices, error) in
do
{
let devices = try Result(devices, error).get()
@@ -323,7 +477,7 @@ extension ALTDeviceManager
}
else
{
ALTAppleAPI.shared.registerDevice(name: device.name, identifier: device.identifier, team: team) { (device, error) in
ALTAppleAPI.shared.registerDevice(name: device.name, identifier: device.identifier, team: team, session: session) { (device, error) in
completionHandler(Result(device, error))
}
}
@@ -335,9 +489,9 @@ extension ALTDeviceManager
}
}
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team) { (profile, error) in
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team, session: session) { (profile, error) in
completionHandler(Result(profile, error))
}
}
@@ -353,7 +507,16 @@ extension ALTDeviceManager
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
infoDictionary[Bundle.Info.deviceID] = device.identifier
infoDictionary[Bundle.Info.serverID] = UserDefaults.standard.serverID
infoDictionary[Bundle.Info.certificateID] = certificate.serialNumber
try (infoDictionary as NSDictionary).write(to: infoPlistURL)
if
let machineIdentifier = certificate.machineIdentifier,
let encryptedData = certificate.encryptedP12Data(withPassword: machineIdentifier)
{
let certificateURL = application.fileURL.appendingPathComponent("ALTCertificate.p12")
try encryptedData.write(to: certificateURL, options: .atomic)
}
let resigner = ALTSigner(team: team, certificate: certificate)
resigner.signApp(at: application.fileURL, provisioningProfiles: [profile]) { (success, error) in
@@ -380,3 +543,45 @@ extension ALTDeviceManager
}
}
}
private var securityCodeAlertKey = 0
private var securityCodeTextFieldKey = 0
extension ALTDeviceManager: NSTextFieldDelegate
{
var securityCodeAlert: NSAlert? {
get { return objc_getAssociatedObject(self, &securityCodeAlertKey) as? NSAlert }
set { objc_setAssociatedObject(self, &securityCodeAlertKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
var securityCodeTextField: NSTextField? {
get { return objc_getAssociatedObject(self, &securityCodeTextFieldKey) as? NSTextField }
set { objc_setAssociatedObject(self, &securityCodeTextFieldKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
public func controlTextDidChange(_ obj: Notification)
{
self.validate()
}
public func controlTextDidEndEditing(_ obj: Notification)
{
self.validate()
}
private func validate()
{
guard let code = self.securityCodeTextField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) else { return }
if code.count == 6
{
self.securityCodeAlert?.buttons.first?.isEnabled = true
}
else
{
self.securityCodeAlert?.buttons.first?.isEnabled = false
}
self.securityCodeAlert?.layout()
}
}

View File

@@ -279,13 +279,23 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError";
return finish(error);
}
plist_t profiles = NULL;
plist_t rawProfiles = NULL;
if (misagent_copy_all(mis, &profiles) != MISAGENT_E_SUCCESS)
if (misagent_copy_all(mis, &rawProfiles) != MISAGENT_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
// For some reason, libplist now fails to parse `rawProfiles` correctly.
// Specifically, it no longer recognizes the nodes in the plist array as "data" nodes.
// However, if we encode it as XML then decode it again, it'll work ¯\_(ツ)_/¯
char *plistXML = nullptr;
uint32_t plistLength = 0;
plist_to_xml(rawProfiles, &plistXML, &plistLength);
plist_t profiles = NULL;
plist_from_xml(plistXML, plistLength, &profiles);
uint32_t profileCount = plist_array_get_size(profiles);
for (int i = 0; i < profileCount; i++)
{
@@ -294,7 +304,7 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError";
{
continue;
}
char *bytes = NULL;
uint64_t length = 0;
@@ -307,9 +317,9 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError";
NSData *data = [NSData dataWithBytes:(const void *)bytes length:length];
ALTProvisioningProfile *provisioningProfile = [[ALTProvisioningProfile alloc] initWithData:data];
if (![provisioningProfile.teamIdentifier isEqualToString:installationProvisioningProfile.teamIdentifier])
if (![provisioningProfile isFreeProvisioningProfile])
{
NSLog(@"Ignoring: %@", installationProvisioningProfile.teamIdentifier);
NSLog(@"Ignoring: %@ (Team: %@)", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier);
continue;
}
@@ -338,14 +348,17 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError";
if (misagent_remove(mis, provisioningProfile.UUID.UUIDString.lowercaseString.UTF8String) == MISAGENT_E_SUCCESS)
{
NSLog(@"Removed provisioning profile: %@", provisioningProfile.UUID);
NSLog(@"Removed provisioning profile: %@ (Team: %@)", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier);
}
else
{
int code = misagent_get_status_code(mis);
NSLog(@"Failed to remove provisioning profile %@. Error Code: %@", provisioningProfile.UUID, @(code));
NSLog(@"Failed to remove provisioning profile %@ (Team: %@). Error Code: %@", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier, @(code));
}
}
plist_free(rawProfiles);
plist_free(profiles);
lockdownd_client_free(client);
client = NULL;
@@ -622,12 +635,12 @@ void ALTDeviceManagerUpdateStatus(plist_t command, plist_t status, void *uuid)
uint64_t code = 0;
instproxy_status_get_error(status, &name, &description, &code);
if ((percent == -1 && progress.completedUnitCount > 0) || code != 0)
if ((percent == -1 && progress.completedUnitCount > 0) || code != 0 || name != NULL)
{
void (^completionHandler)(NSError *) = ALTDeviceManager.sharedManager.installationCompletionHandlers[UUID];
if (completionHandler != nil)
{
if (code != 0)
if (code != 0 || name != NULL)
{
NSLog(@"Error installing app. %@ (%@). %@", @(code), @(name), @(description));
@@ -639,9 +652,16 @@ void ALTDeviceManagerUpdateStatus(plist_t command, plist_t status, void *uuid)
}
else
{
NSError *underlyingError = [NSError errorWithDomain:AltServerInstallationErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: @(description)}];
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorInstallationFailed userInfo:@{NSUnderlyingErrorKey: underlyingError}];
NSString *errorName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
if ([errorName isEqualToString:@"DeviceOSVersionTooLow"])
{
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorUnsupportediOSVersion userInfo:nil];
}
else
{
NSError *underlyingError = [NSError errorWithDomain:AltServerInstallationErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: @(description)}];
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorInstallationFailed userInfo:@{NSUnderlyingErrorKey: underlyingError}];
}
}
completionHandler(error);

View File

@@ -19,6 +19,15 @@ extension UserDefaults
}
}
var didPresentInitialNotification: Bool {
get {
return self.bool(forKey: "didPresentInitialNotification")
}
set {
self.set(newValue, forKey: "didPresentInitialNotification")
}
}
func registerDefaults()
{
if self.serverID == nil

View File

@@ -17,18 +17,20 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2019 Riley Testut. All rights reserved.</string>
<key>NSMainStoryboardFile</key>
<string>Main</string>
<key>LSUIElement</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUFeedURL</key>
<string>https://altstore.io/altserver/sparkle-macos.xml</string>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
#!/bin/sh
# InstallAltPlugin.sh
# AltStore
#
# Created by Riley Testut on 11/16/19.
# Copyright © 2019 Riley Testut. All rights reserved.
rm -f AltPlugin.mailbundle
unzip AltPlugin.mailbundle.zip 1>/dev/null
mkdir -p /Library/Mail/Bundles
cp -r AltPlugin.mailbundle /Library/Mail/Bundles
defaults write "/Library/Preferences/com.apple.mail" EnableBundles 1

View File

@@ -0,0 +1,9 @@
#!/bin/sh
# UninstallPlugin.sh
# AltStore
#
# Created by Riley Testut on 11/16/19.
# Copyright © 2019 Riley Testut. All rights reserved.
rm -rf /Library/Mail/Bundles/AltPlugin.mailbundle

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1120"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
BuildableName = "AltPlugin.mailbundle"
BlueprintName = "AltPlugin"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "1"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
BuildableName = "AltPlugin.mailbundle"
BlueprintName = "AltPlugin"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -4,3 +4,5 @@
#import "NSError+ALTServerError.h"
#import "ALTAppPermission.h"
#import "ALTPatreonBenefitType.h"
#import "ALTSourceUserInfoKey.h"

View File

@@ -10,6 +10,8 @@ import UIKit
import Roxas
import Nuke
extension AppContentViewController
{
private enum Row: Int, CaseIterable
@@ -51,12 +53,10 @@ class AppContentViewController: UITableViewController
@IBOutlet private var screenshotsCollectionView: UICollectionView!
@IBOutlet private var permissionsCollectionView: UICollectionView!
var preferredScreenshotSize: CGSize? {
guard let image = self.screenshotsDataSource.items.first else { return nil }
var preferredScreenshotSize: CGSize? {
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
let aspectRatio = image.size.height / image.size.width
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
@@ -73,6 +73,8 @@ class AppContentViewController: UITableViewController
self.tableView.contentInset.bottom = 20
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
self.permissionsCollectionView.dataSource = self.permissionsDataSource
self.subtitleLabel.text = self.app.subtitle
@@ -125,14 +127,39 @@ class AppContentViewController: UITableViewController
private extension AppContentViewController
{
func makeScreenshotsDataSource() -> RSTArrayCollectionViewDataSource<UIImage>
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
{
let screenshots = self.app.screenshotNames.compactMap(UIImage.init(named:))
let dataSource = RSTArrayCollectionViewDataSource(items: screenshots)
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.image = screenshot
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL as URL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource

View File

@@ -10,6 +10,8 @@ import UIKit
import Roxas
import Nuke
class AppViewController: UIViewController
{
var app: StoreApp!
@@ -25,17 +27,11 @@ class AppViewController: UIViewController
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var contentView: UIView!
@IBOutlet private var headerView: UIView!
@IBOutlet private var headerContentView: UIView!
@IBOutlet private var bannerView: AppBannerView!
@IBOutlet private var backButton: UIButton!
@IBOutlet private var backButtonContainerView: UIVisualEffectView!
@IBOutlet private var nameLabel: UILabel!
@IBOutlet private var developerLabel: UILabel!
@IBOutlet private var downloadButton: PillButton!
@IBOutlet private var appIconImageView: UIImageView!
@IBOutlet private var backgroundAppIconImageView: UIImageView!
@IBOutlet private var backgroundBlurView: UIVisualEffectView!
@@ -48,6 +44,12 @@ class AppViewController: UIViewController
private var _backgroundBlurEffect: UIBlurEffect?
private var _backgroundBlurTintColor: UIColor?
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
override var preferredStatusBarStyle: UIStatusBarStyle {
return _preferredStatusBarStyle
}
override func viewDidLoad()
{
super.viewDidLoad()
@@ -72,28 +74,28 @@ class AppViewController: UIViewController
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
self.contentViewController.tableView.showsVerticalScrollIndicator = false
self.headerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
self.headerView.layer.cornerRadius = 24
self.headerView.layer.masksToBounds = true
// Bring to front so the scroll indicators are visible.
self.view.bringSubviewToFront(self.scrollView)
self.scrollView.isUserInteractionEnabled = false
self.nameLabel.text = self.app.name
self.developerLabel.text = self.app.developerName
self.developerLabel.textColor = self.app.tintColor
self.appIconImageView.image = UIImage(named: self.app.iconName)
self.appIconImageView.tintColor = self.app.tintColor
self.downloadButton.tintColor = self.app.tintColor
self.backgroundAppIconImageView.image = UIImage(named: self.app.iconName)
self.bannerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
self.bannerView.backgroundEffectView.effect = UIBlurEffect(style: .regular)
self.bannerView.backgroundEffectView.backgroundColor = .clear
self.bannerView.titleLabel.text = self.app.name
self.bannerView.subtitleLabel.text = self.app.developerName
self.bannerView.iconImageView.image = nil
self.bannerView.iconImageView.tintColor = self.app.tintColor
self.bannerView.button.tintColor = self.app.tintColor
self.bannerView.betaBadgeView.isHidden = !self.app.isBeta
self.bannerView.tintColor = self.app.tintColor
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
self.backButtonContainerView.tintColor = self.app.tintColor
self.navigationController?.navigationBar.tintColor = self.app.tintColor
self.navigationBarDownloadButton.tintColor = self.app.tintColor
self.navigationBarAppNameLabel.text = self.app.name
self.navigationBarAppIconImageView.image = UIImage(named: self.app.iconName)
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
self.contentSizeObservation = self.contentViewController.tableView.observe(\.contentSize) { [weak self] (tableView, change) in
@@ -105,9 +107,23 @@ class AppViewController: UIViewController
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didChangeApp(_:)), name: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
// Load Images
for imageView in [self.bannerView.iconImageView!, self.backgroundAppIconImageView!, self.navigationBarAppIconImageView!]
{
imageView.isIndicatingActivity = true
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (response, error) in
if response?.image != nil
{
imageView?.isIndicatingActivity = false
}
}
}
}
override func viewWillAppear(_ animated: Bool)
@@ -204,7 +220,7 @@ class AppViewController: UIViewController
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
width: backButtonSize.width + 20, height: backButtonSize.height + 20)
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.headerView.bounds.height)
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.bannerView.bounds.height)
var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width)
@@ -290,12 +306,11 @@ class AppViewController: UIViewController
// Set frames.
self.contentViewController.view.superview?.frame = contentFrame
self.headerView.frame = headerFrame
self.bannerView.frame = headerFrame
self.backgroundAppIconImageView.frame = backgroundIconFrame
self.backgroundBlurView.frame = backgroundIconFrame
self.backButtonContainerView.frame = backButtonFrame
self.headerContentView.frame = CGRect(x: 0, y: 0, width: self.headerView.bounds.width, height: self.headerView.bounds.height)
self.contentViewControllerShadowView.frame = self.contentViewController.view.frame
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
@@ -310,6 +325,14 @@ class AppViewController: UIViewController
self.scrollView.contentSize = contentSize
self.scrollView.contentOffset = contentOffset
self.bannerView.backgroundEffectView.backgroundColor = .clear
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
{
super.traitCollectionDidChange(previousTraitCollection)
self._shouldResetLayout = true
}
deinit
@@ -335,7 +358,7 @@ private extension AppViewController
{
func update()
{
for button in [self.downloadButton!, self.navigationBarDownloadButton!]
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
{
button.tintColor = self.app.tintColor
button.isIndicatingActivity = false
@@ -343,18 +366,27 @@ private extension AppViewController
if self.app.installedApp == nil
{
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
button.isInverted = false
}
else
{
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
button.isInverted = true
}
let progress = AppManager.shared.installationProgress(for: self.app)
button.progress = progress
}
if Date() < self.app.versionDate
{
self.bannerView.button.countdownDate = self.app.versionDate
self.navigationBarDownloadButton.countdownDate = self.app.versionDate
}
else
{
self.bannerView.button.countdownDate = nil
self.navigationBarDownloadButton.countdownDate = nil
}
let barButtonItem = self.navigationItem.rightBarButtonItem
self.navigationItem.rightBarButtonItem = nil
self.navigationItem.rightBarButtonItem = barButtonItem
@@ -363,18 +395,29 @@ private extension AppViewController
func showNavigationBar(for navigationController: UINavigationController? = nil)
{
let navigationController = navigationController ?? self.navigationController
navigationController?.navigationBar.barStyle = .default
navigationController?.navigationBar.alpha = 1.0
navigationController?.navigationBar.barTintColor = .white
navigationController?.navigationBar.tintColor = .altGreen
navigationController?.navigationBar.tintColor = .altPrimary
navigationController?.navigationBar.setNeedsLayout()
if self.traitCollection.userInterfaceStyle == .dark
{
self._preferredStatusBarStyle = .lightContent
}
else
{
self._preferredStatusBarStyle = .default
}
navigationController?.setNeedsStatusBarAppearanceUpdate()
}
func hideNavigationBar(for navigationController: UINavigationController? = nil)
{
let navigationController = navigationController ?? self.navigationController
navigationController?.navigationBar.barStyle = .black
navigationController?.navigationBar.alpha = 0.0
navigationController?.navigationBar.barTintColor = .white
self._preferredStatusBarStyle = .lightContent
navigationController?.setNeedsStatusBarAppearanceUpdate()
}
func prepareBlur()
@@ -419,7 +462,7 @@ private extension AppViewController
self.navigationBarAnimator = nil
self.hideNavigationBar()
self.navigationController?.navigationBar.barTintColor = .white
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
}
}
@@ -465,12 +508,14 @@ extension AppViewController
}
DispatchQueue.main.async {
self.downloadButton.progress = nil
self.bannerView.button.progress = nil
self.navigationBarDownloadButton.progress = nil
self.update()
}
}
self.downloadButton.progress = progress
self.bannerView.button.progress = progress
self.navigationBarDownloadButton.progress = progress
}
func open(_ installedApp: InstalledApp)
@@ -496,6 +541,15 @@ private extension AppViewController
self._shouldResetLayout = true
self.view.setNeedsLayout()
}
@objc func didBecomeActive(_ notification: Notification)
{
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
// Fixes Navigation Bar appearing after app becomes inactive -> active again.
self._shouldResetLayout = true
self.view.setNeedsLayout()
}
}
extension AppViewController: UIScrollViewDelegate

View File

@@ -11,6 +11,7 @@ import UserNotifications
import AVFoundation
import AltSign
import AltKit
import Roxas
private enum RefreshError: LocalizedError
@@ -49,6 +50,14 @@ private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, Uns
appDelegate.receivedApplicationState(notification: name)
}
extension AppDelegate
{
static let openPatreonSettingsDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.OpenPatreonSettingsDeepLinkNotification")
static let importAppDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.ImportAppDeepLinkNotification")
static let importAppDeepLinkURLKey = "fileURL"
}
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -62,12 +71,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
ServerManager.shared.startDiscovering()
UserDefaults.standard.registerDefaults()
if UserDefaults.standard.firstLaunch == nil
{
Keychain.shared.reset()
UserDefaults.standard.firstLaunch = Date()
}
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
#if DEBUG || BETA
UserDefaults.standard.isDebugModeEnabled = true
#endif
self.prepareForBackgroundFetch()
return true
@@ -82,6 +99,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
{
AppManager.shared.update()
ServerManager.shared.startDiscovering()
PatreonAPI.shared.refreshPatreonAccount()
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
{
return self.open(url)
}
}
@@ -89,7 +113,32 @@ private extension AppDelegate
{
func setTintColor()
{
self.window?.tintColor = .altGreen
self.window?.tintColor = .altPrimary
}
func open(_ url: URL) -> Bool
{
if url.isFileURL
{
guard url.pathExtension.lowercased() == "ipa" else { return false }
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: url])
}
return true
}
else
{
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
guard let host = components.host, host.lowercased() == "patreon" else { return false }
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
}
return true
}
}
}
@@ -125,7 +174,25 @@ extension AppDelegate
func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void)
{
ServerManager.shared.startDiscovering()
if UserDefaults.standard.isBackgroundRefreshEnabled
{
ServerManager.shared.startDiscovering()
if !UserDefaults.standard.presentedLaunchReminderNotification
{
let threeHours: TimeInterval = 3 * 60 * 60
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false)
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("App Refresh Tip", comment: "")
content.body = NSLocalizedString("The more you open AltStore, the more chances it's given to refresh apps in the background.", comment: "")
let request = UNNotificationRequest(identifier: "background-refresh-reminder5", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
UserDefaults.standard.presentedLaunchReminderNotification = true
}
}
let refreshIdentifier = UUID().uuidString
@@ -135,9 +202,11 @@ extension AppDelegate
{
// If finish is actually called, that means an error occured during installation.
ServerManager.shared.stopDiscovering()
self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, delay: 0)
if UserDefaults.standard.isBackgroundRefreshEnabled
{
ServerManager.shared.stopDiscovering()
self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, delay: 0)
}
taskCompletionHandler()
}
@@ -178,87 +247,195 @@ private extension AppDelegate
backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
completionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void)
{
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
var fetchSourceResult: Result<Source, Error>?
var serversResult: Result<Void, Error>?
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
AppManager.shared.fetchSource() { (result) in
fetchSourceResult = result
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
guard !installedApps.isEmpty else {
backgroundFetchCompletionHandler(.noData)
completionHandler(.failure(RefreshError.noInstalledApps))
return
}
self.runningApplications = []
let identifiers = installedApps.compactMap { $0.bundleIdentifier }
print("Apps to refresh:", identifiers)
DispatchQueue.global().async {
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
do
{
let source = try result.get()
for identifier in identifiers
guard let context = source.managedObjectContext else { return }
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
previousUpdatesFetchRequest.includesPendingChanges = false
previousUpdatesFetchRequest.resultType = .dictionaryResultType
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
previousNewsItemsFetchRequest.includesPendingChanges = false
previousNewsItemsFetchRequest.resultType = .dictionaryResultType
previousNewsItemsFetchRequest.propertiesToFetch = [#keyPath(NewsItem.identifier)]
let previousUpdates = try context.fetch(previousUpdatesFetchRequest) as! [[String: String]]
let previousNewsItems = try context.fetch(previousNewsItemsFetchRequest) as! [[String: String]]
try context.save()
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
let updates = try context.fetch(updatesFetchRequest)
let newsItems = try context.fetch(newsItemsFetchRequest)
for update in updates
{
let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier)
CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately)
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
guard let storeApp = update.storeApp else { continue }
let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier)
CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true)
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("New Update Available", comment: "")
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, storeApp.version)
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
var fetchSourceResult: Result<Source, Error>?
var serversResult: Result<Void, Error>?
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
dispatchGroup.enter()
AppManager.shared.fetchSource() { (result) in
fetchSourceResult = result
dispatchGroup.leave()
do
for newsItem in newsItems
{
let source = try result.get()
guard !previousNewsItems.contains(where: { $0[#keyPath(NewsItem.identifier)] == newsItem.identifier }) else { continue }
guard !newsItem.isSilent else { continue }
guard let context = source.managedObjectContext else { return }
let content = UNMutableNotificationContent()
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
updatesFetchRequest.includesPendingChanges = true
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest()
previousUpdatesFetchRequest.includesPendingChanges = false
let previousUpdates = try context.fetch(previousUpdatesFetchRequest)
try context.save()
let updates = try context.fetch(updatesFetchRequest)
for update in updates
if let app = newsItem.storeApp
{
guard !previousUpdates.contains(where: { $0.bundleIdentifier == update.bundleIdentifier }) else { continue }
guard let storeApp = update.storeApp else { continue }
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("New Update Available", comment: "")
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, storeApp.version)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
content.title = String(format: NSLocalizedString("%@ News", comment: ""), app.name)
}
else
{
content.title = NSLocalizedString("AltStore News", comment: "")
}
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = updates.count
}
content.body = newsItem.title
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
catch
{
print("Error fetching apps:", error)
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = updates.count
}
}
catch
{
print("Error fetching apps:", error)
fetchSourceResult = .failure(error)
}
dispatchGroup.notify(queue: .main) {
dispatchGroup.leave()
}
if UserDefaults.standard.isBackgroundRefreshEnabled
{
dispatchGroup.enter()
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
guard !installedApps.isEmpty else {
serversResult = .success(())
dispatchGroup.leave()
completionHandler(.failure(RefreshError.noInstalledApps))
return
}
self.runningApplications = []
let identifiers = installedApps.compactMap { $0.bundleIdentifier }
print("Apps to refresh:", identifiers)
DispatchQueue.global().async {
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
for identifier in identifiers
{
let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier)
CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately)
let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier)
CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true)
}
}
// Wait for three seconds to:
// a) give us time to discover AltServers
// b) give other processes a chance to respond to requestAppState notification
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
context.perform {
if ServerManager.shared.discoveredServers.isEmpty
{
serversResult = .failure(ConnectionError.serverNotFound)
}
else
{
serversResult = .success(())
}
dispatchGroup.leave()
let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) }
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
group.beginInstallationHandler = { (installedApp) in
guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return }
// We're starting to install AltStore, which means the app is about to quit.
// So, we schedule a "refresh successful" local notification to be displayed after a delay,
// but if the app is still running, we cancel the notification.
// Then, we schedule another notification and repeat the process.
// Also since AltServer has already received the app, it can finish installing even if we're no longer running in background.
if let error = group.error
{
self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: identifier)
}
else
{
var results = group.results
results[installedApp.bundleIdentifier] = .success(installedApp)
self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: identifier)
}
}
group.completionHandler = { (result) in
completionHandler(result)
}
}
}
}
}
dispatchGroup.notify(queue: .main) {
if !UserDefaults.standard.isBackgroundRefreshEnabled
{
guard let fetchSourceResult = fetchSourceResult else {
backgroundFetchCompletionHandler(.failed)
return
}
switch fetchSourceResult
{
case .failure: backgroundFetchCompletionHandler(.failed)
case .success: backgroundFetchCompletionHandler(.newData)
}
completionHandler(.success([:]))
}
else
{
guard let fetchSourceResult = fetchSourceResult, let serversResult = serversResult else {
backgroundFetchCompletionHandler(.failed)
return
@@ -272,54 +449,6 @@ private extension AppDelegate
case (.failure, _), (_, .failure): backgroundFetchCompletionHandler(.failed)
}
}
// Wait for three seconds to:
// a) give us time to discover AltServers
// b) give other processes a chance to respond to requestAppState notification
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
context.perform {
if ServerManager.shared.discoveredServers.isEmpty
{
serversResult = .failure(ConnectionError.serverNotFound)
}
else
{
serversResult = .success(())
}
dispatchGroup.leave()
let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) }
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
group.beginInstallationHandler = { (installedApp) in
guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return }
// We're starting to install AltStore, which means the app is about to quit.
// So, we schedule a "refresh successful" local notification to be displayed after a delay,
// but if the app is still running, we cancel the notification.
// Then, we schedule another notification and repeat the process.
// Also since AltServer has already received the app, it can finish installing even if we're no longer running in background.
if let error = group.error
{
self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: identifier)
}
else
{
var results = group.results
results[installedApp.bundleIdentifier] = .success(installedApp)
self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: identifier)
}
}
group.completionHandler = { (result) in
completionHandler(result)
}
}
}
}
}

View File

@@ -1,242 +1,508 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15703"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Apple ID-->
<scene sceneID="3cc-cd-zDK">
<!--Navigation Controller-->
<scene sceneID="lNR-II-WoW">
<objects>
<tableViewController storyboardIdentifier="authenticationViewController" id="nRn-xt-2XS" customClass="AuthenticationViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="44" estimatedRowHeight="44" sectionHeaderHeight="18" sectionFooterHeight="18" id="r38-H3-S3C">
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="barTintColor" name="SettingsBackground"/>
<textAttributes key="titleTextAttributes">
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</textAttributes>
<textAttributes key="largeTitleTextAttributes">
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</textAttributes>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="boolean" keyPath="automaticallyAdjustsItemPositions" value="NO"/>
</userDefinedRuntimeAttributes>
</navigationBar>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="9J6-jc-46k" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-164" y="735"/>
</scene>
<!--Authentication View Controller-->
<scene sceneID="OCd-xc-Ms7">
<objects>
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<sections>
<tableViewSection id="uDm-cx-LdY">
<string key="footerTitle">Your email address and password are used only to sign in with Apple and is never stored.
If you have two-factor authentication enabled, make sure to use an app-specific password.</string>
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="ER5-4r-tld">
<rect key="frame" x="0.0" y="35" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ER5-4r-tld" id="BnC-HI-d8z">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="70T-cn-6XF">
<rect key="frame" x="16" y="0.0" width="343" height="43.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apple ID" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="09n-b4-DRC">
<rect key="frame" x="0.0" y="0.0" width="74" height="43.5"/>
<constraints>
<constraint firstAttribute="width" constant="74" id="Y87-hZ-IsD"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Email Address" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="V6B-NM-wpL">
<rect key="frame" x="90" y="0.0" width="253" height="43.5"/>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" returnKeyType="next" enablesReturnKeyAutomatically="YES" textContentType="email"/>
<connections>
<outlet property="delegate" destination="nRn-xt-2XS" id="5Us-OB-B4F"/>
</connections>
</textField>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="70T-cn-6XF" firstAttribute="top" secondItem="BnC-HI-d8z" secondAttribute="top" id="Zyt-OB-o6T"/>
<constraint firstAttribute="trailingMargin" secondItem="70T-cn-6XF" secondAttribute="trailing" id="lYn-uy-vRk"/>
<constraint firstAttribute="bottom" secondItem="70T-cn-6XF" secondAttribute="bottom" id="urj-EQ-5WK"/>
<constraint firstItem="70T-cn-6XF" firstAttribute="leading" secondItem="BnC-HI-d8z" secondAttribute="leadingMargin" id="yqr-Kr-I93"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="E9B-Cb-M5e">
<rect key="frame" x="0.0" y="79" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="E9B-Cb-M5e" id="S4n-4w-12m">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="pON-cO-VYR">
<rect key="frame" x="16" y="0.0" width="343" height="43.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Password" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Vqv-cC-kya">
<rect key="frame" x="0.0" y="0.0" width="74" height="43.5"/>
<constraints>
<constraint firstAttribute="width" constant="74" id="Egk-ba-Kh3"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Password" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="z98-Sm-yDv">
<rect key="frame" x="90" y="0.0" width="253" height="43.5"/>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" returnKeyType="go" enablesReturnKeyAutomatically="YES" secureTextEntry="YES" textContentType="password"/>
<connections>
<outlet property="delegate" destination="nRn-xt-2XS" id="7pH-Sf-Wmb"/>
</connections>
</textField>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="pON-cO-VYR" secondAttribute="trailing" id="IPH-Og-2ch"/>
<constraint firstAttribute="bottom" secondItem="pON-cO-VYR" secondAttribute="bottom" id="j7H-Ds-pJg"/>
<constraint firstItem="pON-cO-VYR" firstAttribute="leading" secondItem="S4n-4w-12m" secondAttribute="leadingMargin" id="uAc-4j-0pB"/>
<constraint firstItem="pON-cO-VYR" firstAttribute="top" secondItem="S4n-4w-12m" secondAttribute="top" id="xZe-CS-STZ"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="nRn-xt-2XS" id="VWO-oe-ykv"/>
<outlet property="delegate" destination="nRn-xt-2XS" id="CL1-Go-uiO"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Apple ID" id="viw-66-ZJ7">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="KXh-qW-MIA">
<subviews>
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="oyW-Fd-ojD" userLabel="Sizing View">
<rect key="frame" x="0.0" y="44" width="375" height="623"/>
</view>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" indicatorStyle="white" keyboardDismissMode="onDrag" translatesAutoresizingMaskIntoConstraints="NO" id="WXx-hX-AXv">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<subviews>
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2wp-qG-f0Z">
<rect key="frame" x="0.0" y="0.0" width="375" height="623"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" spacing="50" translatesAutoresizingMaskIntoConstraints="NO" id="YmX-7v-pxh">
<rect key="frame" x="16" y="6" width="343" height="359.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="Yfu-hI-0B7" userLabel="Welcome">
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to AltStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
<rect key="frame" x="0.0" y="0.0" width="333.5" height="41"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
<rect key="frame" x="0.0" y="47" width="308.5" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="32" translatesAutoresizingMaskIntoConstraints="NO" id="Aqh-MD-HFf">
<rect key="frame" x="0.0" y="117.5" width="343" height="242"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="Oy6-xr-cZ7">
<rect key="frame" x="0.0" y="0.0" width="343" height="159"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="H95-7V-Kk8" userLabel="Apple ID">
<rect key="frame" x="0.0" y="0.0" width="343" height="72"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="KN1-Kp-M1q">
<rect key="frame" x="0.0" y="0.0" width="343" height="17"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="APPLE ID" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="59N-O1-6bM">
<rect key="frame" x="14" y="0.0" width="329" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="0.0"/>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="gNe-dC-oI1">
<rect key="frame" x="0.0" y="21" width="343" height="51"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="name@email.com" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="DBu-vt-hlo">
<rect key="frame" x="14" y="0.0" width="315" height="51"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="19"/>
<textInputTraits key="textInputTraits" returnKeyType="next" textContentType="email"/>
<connections>
<outlet property="delegate" destination="yO1-iT-7NP" id="G13-jV-DLX"/>
</connections>
</textField>
</subviews>
<color key="backgroundColor" white="1" alpha="0.29999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="DBu-vt-hlo" secondAttribute="trailing" id="0Lf-vH-juh"/>
<constraint firstItem="DBu-vt-hlo" firstAttribute="centerY" secondItem="gNe-dC-oI1" secondAttribute="centerY" id="kgs-hg-ECM"/>
<constraint firstItem="DBu-vt-hlo" firstAttribute="height" secondItem="gNe-dC-oI1" secondAttribute="height" id="n7y-Xg-8MP"/>
<constraint firstItem="DBu-vt-hlo" firstAttribute="leading" secondItem="gNe-dC-oI1" secondAttribute="leadingMargin" id="sat-rb-OIu"/>
<constraint firstAttribute="height" constant="51" id="tuP-Uo-6qp"/>
</constraints>
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="14"/>
</view>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="hd5-yc-rcq" userLabel="Password">
<rect key="frame" x="0.0" y="87" width="343" height="72"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="lvX-im-C95">
<rect key="frame" x="0.0" y="0.0" width="343" height="17"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="PASSWORD" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ava-XY-7vs">
<rect key="frame" x="14" y="0.0" width="329" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="0.0"/>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cLc-iA-yq5">
<rect key="frame" x="0.0" y="21" width="343" height="51"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="••••••••" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="R77-TQ-lVT">
<rect key="frame" x="14" y="0.0" width="315" height="51"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="19"/>
<textInputTraits key="textInputTraits" returnKeyType="go" enablesReturnKeyAutomatically="YES" secureTextEntry="YES" textContentType="password"/>
<connections>
<outlet property="delegate" destination="yO1-iT-7NP" id="Wpg-DV-BNL"/>
</connections>
</textField>
</subviews>
<color key="backgroundColor" white="1" alpha="0.29999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="R77-TQ-lVT" firstAttribute="leading" secondItem="cLc-iA-yq5" secondAttribute="leadingMargin" id="130-RD-MwU"/>
<constraint firstAttribute="height" constant="51" id="9Jw-2V-fgf"/>
<constraint firstItem="R77-TQ-lVT" firstAttribute="height" secondItem="cLc-iA-yq5" secondAttribute="height" id="FFf-Bp-LPT"/>
<constraint firstItem="R77-TQ-lVT" firstAttribute="centerY" secondItem="cLc-iA-yq5" secondAttribute="centerY" id="agB-KM-ba3"/>
<constraint firstAttribute="trailingMargin" secondItem="R77-TQ-lVT" secondAttribute="trailing" id="jB5-Ye-cJB"/>
</constraints>
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="14"/>
</view>
</subviews>
</stackView>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
<color key="backgroundColor" name="SettingsHighlighted"/>
<constraints>
<constraint firstAttribute="height" constant="51" id="4BK-Un-5pl"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
<state key="normal" title="Sign in">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<connections>
<action selector="authenticate" destination="yO1-iT-7NP" eventType="primaryActionTriggered" id="LER-a2-CbC"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="250" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
<rect key="frame" x="16" y="518.5" width="343" height="96.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Why do we need this?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p9U-0q-Kn8">
<rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="249" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.25" translatesAutoresizingMaskIntoConstraints="NO" id="on2-62-waY">
<rect key="frame" x="0.0" y="24.5" width="343" height="72"/>
<string key="text">Your Apple ID is used to configure apps so they can be installed on this device. Your credentials will be stored securely in this device's Keychain and sent only to Apple for authentication.</string>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="DBk-rT-ZE8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="YmX-7v-pxh" secondAttribute="bottom" constant="8" symbolic="YES" id="zTU-eY-DWd"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="leading" secondItem="WXx-hX-AXv" secondAttribute="leading" id="13j-ii-X7W"/>
<constraint firstAttribute="bottom" secondItem="2wp-qG-f0Z" secondAttribute="bottom" id="Ggl-es-C4C"/>
<constraint firstAttribute="trailing" secondItem="2wp-qG-f0Z" secondAttribute="trailing" id="nl1-88-5mM"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="top" secondItem="WXx-hX-AXv" secondAttribute="top" id="wiH-lv-L9P"/>
</constraints>
</scrollView>
</subviews>
<color key="backgroundColor" name="SettingsBackground"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
<constraint firstAttribute="leadingMargin" secondItem="YmX-7v-pxh" secondAttribute="leading" id="2PO-lG-dmB"/>
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
<constraint firstItem="oyW-Fd-ojD" firstAttribute="top" secondItem="zMn-DV-fpy" secondAttribute="top" id="730-db-ukB"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="oyW-Fd-ojD" secondAttribute="trailing" id="KGE-CN-SWf"/>
<constraint firstItem="WXx-hX-AXv" firstAttribute="top" secondItem="mjy-4S-hyH" secondAttribute="top" id="LPQ-bF-ic0"/>
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="WXx-hX-AXv" secondAttribute="trailing" id="MG7-A6-pKp"/>
<constraint firstAttribute="trailingMargin" secondItem="YmX-7v-pxh" secondAttribute="trailing" id="O4T-nu-o3e"/>
<constraint firstItem="zMn-DV-fpy" firstAttribute="bottom" secondItem="oyW-Fd-ojD" secondAttribute="bottom" id="PuX-ab-cEq"/>
<constraint firstItem="oyW-Fd-ojD" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="SzC-gC-Nvi"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
<constraint firstItem="WXx-hX-AXv" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="d08-zF-5X6"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="height" secondItem="oyW-Fd-ojD" secondAttribute="height" id="dFN-pw-TWt"/>
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
</constraints>
<viewLayoutGuide key="safeArea" id="zMn-DV-fpy"/>
</view>
<toolbarItems/>
<navigationItem key="navigationItem" largeTitleDisplayMode="never" id="jCf-N4-xVD">
<barButtonItem key="leftBarButtonItem" title="Close" id="nDc-Zs-wnK">
<connections>
<action selector="cancel" destination="nRn-xt-2XS" id="l1X-bA-xsz"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" title="Sign In" style="done" id="mkE-Q8-CxO">
<connections>
<action selector="authenticate" destination="nRn-xt-2XS" id="q60-9N-xVb"/>
<action selector="cancel:" destination="yO1-iT-7NP" id="xls-in-Pre"/>
</connections>
</barButtonItem>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<nil key="simulatedBottomBarMetrics"/>
<connections>
<outlet property="appleIDBackgroundView" destination="gNe-dC-oI1" id="lab-WG-pyJ"/>
<outlet property="appleIDTextField" destination="DBu-vt-hlo" id="ZMK-9K-phY"/>
<outlet property="contentStackView" destination="YmX-7v-pxh" id="ZX5-Af-cEB"/>
<outlet property="passwordBackgroundView" destination="cLc-iA-yq5" id="2JD-nS-Gf7"/>
<outlet property="passwordTextField" destination="R77-TQ-lVT" id="cLQ-Wn-MsE"/>
<outlet property="scrollView" destination="WXx-hX-AXv" id="hOb-gl-0OP"/>
<outlet property="signInButton" destination="2N5-zd-fUj" id="ul1-bh-4l4"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="U7A-Cx-Bo9" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="605.60000000000002" y="736.28185907046486"/>
</scene>
<!--How it works-->
<scene sceneID="dMt-EA-SGy">
<objects>
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
<rect key="frame" x="0.0" y="44" width="375" height="564"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="FjP-tm-w7K">
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="1" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i9V-3h-B8f">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
<constraints>
<constraint firstAttribute="width" constant="59" id="ILg-0e-PW8"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Q20-ml-9D0">
<rect key="frame" x="79" y="16" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Launch AltServer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="XKD-XH-eB0">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Leave AltServer running in the background on your computer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="6HP-Xh-sAH">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
<rect key="frame" x="16" y="168" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0LW-eE-qHa">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
<constraints>
<constraint firstAttribute="width" constant="59" id="HzE-AA-eE5"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to WiFi" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable iTunes WiFi Sync and connect to the same WiFi as AltServer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
<rect key="frame" x="16" y="300.5" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="3" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nVr-El-Csi">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
<constraints>
<constraint firstAttribute="width" constant="59" id="fRj-b4-VTe"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
<rect key="frame" x="79" y="16" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Download Apps" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="JeJ-bk-UCA">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Browse and download apps directly from AltStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M7T-9j-uyt">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="X3r-G1-vf2">
<rect key="frame" x="16" y="433.5" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="4" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i2U-NL-plG">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
<constraints>
<constraint firstAttribute="width" constant="59" id="4Qg-s9-p7s"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
<rect key="frame" x="79" y="16" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps Refresh Automatically" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background when on same WiFi as AltServer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
</subviews>
<edgeInsets key="layoutMargins" top="35" left="0.0" bottom="35" right="0.0"/>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
<rect key="frame" x="16" y="608" width="343" height="51"/>
<color key="backgroundColor" name="SettingsHighlighted"/>
<constraints>
<constraint firstAttribute="height" constant="51" id="LQz-qG-ZJK"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
<state key="normal" title="Got it">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<connections>
<action selector="dismiss" destination="aFi-fb-W0B" eventType="primaryActionTriggered" id="sBq-zj-Mln"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" name="SettingsBackground"/>
<constraints>
<constraint firstItem="qZ9-AR-2zK" firstAttribute="top" secondItem="bp6-55-IG2" secondAttribute="bottom" id="3yt-cr-swd"/>
<constraint firstItem="bp6-55-IG2" firstAttribute="top" secondItem="Zek-aC-HOO" secondAttribute="top" id="42S-q2-YZn"/>
<constraint firstAttribute="trailingMargin" secondItem="qZ9-AR-2zK" secondAttribute="trailing" id="8b4-iU-U7R"/>
<constraint firstItem="bp6-55-IG2" firstAttribute="leading" secondItem="Zek-aC-HOO" secondAttribute="leading" id="K1R-1r-FP3"/>
<constraint firstItem="Zek-aC-HOO" firstAttribute="trailing" secondItem="bp6-55-IG2" secondAttribute="trailing" id="aKV-sS-alh"/>
<constraint firstAttribute="bottomMargin" secondItem="qZ9-AR-2zK" secondAttribute="bottom" id="e8e-9l-Mkt"/>
<constraint firstItem="qZ9-AR-2zK" firstAttribute="leading" secondItem="Otz-hn-WGS" secondAttribute="leadingMargin" id="t2b-3e-6ld"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Zek-aC-HOO"/>
</view>
<navigationItem key="navigationItem" title="How it works" largeTitleDisplayMode="always" id="bCq-Jq-gf1"/>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<connections>
<outlet property="emailAddressTextField" destination="V6B-NM-wpL" id="N3F-eI-yhE"/>
<outlet property="passwordTextField" destination="z98-Sm-yDv" id="WDu-6c-oBa"/>
<outlet property="contentStackView" destination="bp6-55-IG2" id="k0Q-yS-Dxp"/>
<outlet property="dismissButton" destination="qZ9-AR-2zK" id="w5c-v6-TcC"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="v2u-D2-stc" userLabel="First Responder" sceneMemberID="firstResponder"/>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="3Q4-ya-qhc" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="605.60000000000002" y="19.340329835082461"/>
<point key="canvasLocation" x="1353" y="736"/>
</scene>
<!--Select Team-->
<scene sceneID="0Hb-4t-vQ3">
<!--Refresh AltStore-->
<scene sceneID="9Vh-dM-OqX">
<objects>
<tableViewController storyboardIdentifier="selectTeamViewController" id="R11-Yh-Wb1" customClass="SelectTeamViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="g2d-7w-OVl">
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="R83-kV-365">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" textLabel="iCV-rW-IhB" detailTextLabel="2hi-el-KvN" style="IBUITableViewCellStyleSubtitle" id="pPa-pY-koy">
<rect key="frame" x="0.0" y="55.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="pPa-pY-koy" id="DjO-Wt-6j2">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="iCV-rW-IhB">
<rect key="frame" x="16" y="5" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="2hi-el-KvN">
<rect key="frame" x="16" y="25.5" width="33" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="R11-Yh-Wb1" id="zkX-xW-GvZ"/>
<outlet property="delegate" destination="R11-Yh-Wb1" id="vP7-NA-Y0n"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Select Team" id="ALr-U3-Ucl">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="HUE-P1-xa1">
<connections>
<action selector="cancel" destination="R11-Yh-Wb1" id="Ckg-bQ-0nv"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" title="Next" style="done" id="7Ou-hQ-Cr3">
<connections>
<action selector="chooseTeam:" destination="R11-Yh-Wb1" id="nin-nM-lxU"/>
</connections>
</barButtonItem>
</navigationItem>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fpO-Bf-gFY" customClass="RSTPlaceholderView">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
</view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="tDQ-ao-1Jg">
<rect key="frame" x="16" y="570" width="343" height="89"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
<color key="backgroundColor" name="SettingsHighlighted"/>
<constraints>
<constraint firstAttribute="height" constant="51" id="SJA-N9-Z6u"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
<color key="tintColor" name="SettingsHighlighted"/>
<state key="normal" title="Refresh Now">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<connections>
<action selector="refreshAltStore:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="WQu-9b-Zgg"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
<rect key="frame" x="0.0" y="59" width="343" height="30"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
<state key="normal" title="Refresh Later">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<connections>
<action selector="cancel:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="ffO-0a-LdE"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" name="SettingsBackground"/>
<constraints>
<constraint firstItem="fpO-Bf-gFY" firstAttribute="leading" secondItem="iwE-xE-ziz" secondAttribute="leading" id="A77-nX-Wg2"/>
<constraint firstAttribute="trailingMargin" secondItem="tDQ-ao-1Jg" secondAttribute="trailing" id="KPg-sO-Rnc"/>
<constraint firstItem="fpO-Bf-gFY" firstAttribute="trailing" secondItem="iwE-xE-ziz" secondAttribute="trailing" id="SGI-1D-Eaw"/>
<constraint firstItem="fpO-Bf-gFY" firstAttribute="bottom" secondItem="R83-kV-365" secondAttribute="bottom" id="cHl-7X-dW1"/>
<constraint firstAttribute="bottomMargin" secondItem="tDQ-ao-1Jg" secondAttribute="bottom" id="kLN-e7-BJE"/>
<constraint firstItem="fpO-Bf-gFY" firstAttribute="top" secondItem="R83-kV-365" secondAttribute="top" id="oKo-10-7kD"/>
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
</constraints>
<viewLayoutGuide key="safeArea" id="iwE-xE-ziz"/>
</view>
<navigationItem key="navigationItem" title="Refresh AltStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="HxT-dJ-1Ry" userLabel="First Responder" sceneMemberID="firstResponder"/>
<connections>
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1354" y="20"/>
</scene>
<!--Replace Certificate-->
<scene sceneID="fW2-QW-a2Z">
<objects>
<tableViewController storyboardIdentifier="replaceCertificateViewController" id="LAG-dk-a0f" customClass="ReplaceCertificateViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="enT-LI-CNI">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" textLabel="luH-7x-QoO" style="IBUITableViewCellStyleDefault" id="i0O-XG-rRJ">
<rect key="frame" x="0.0" y="55.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="i0O-XG-rRJ" id="GCT-3I-GCy">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="luH-7x-QoO">
<rect key="frame" x="16" y="0.0" width="343" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="LAG-dk-a0f" id="kOS-KX-Duz"/>
<outlet property="delegate" destination="LAG-dk-a0f" id="plW-kJ-BmR"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Replace Certificate" id="BM2-Vg-AJk">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="lPC-Dj-3Ik">
<connections>
<action selector="cancel" destination="LAG-dk-a0f" id="5C2-Hg-Les"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" title="Next" style="done" id="ndJ-l9-HeM">
<connections>
<action selector="replaceCertificate:" destination="LAG-dk-a0f" id="vl2-E6-qi4"/>
</connections>
</barButtonItem>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="yxU-EG-3sE" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2135" y="19"/>
<point key="canvasLocation" x="2101.5999999999999" y="733.5832083958021"/>
</scene>
</scenes>
<color key="tintColor" name="Purple"/>
<resources>
<namedColor name="SettingsBackground">
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="SettingsHighlighted">
<color red="0.0080000003799796104" green="0.32199999690055847" blue="0.40400001406669617" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
<color key="tintColor" name="Primary"/>
</document>

View File

@@ -2,48 +2,60 @@
// AuthenticationViewController.swift
// AltStore
//
// Created by Riley Testut on 6/5/19.
// Created by Riley Testut on 9/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltSign
import Roxas
class AuthenticationViewController: UITableViewController
class AuthenticationViewController: UIViewController
{
var authenticationHandler: (((ALTAccount, String)?) -> Void)?
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
private var _didLayoutSubviews = false
private weak var toastView: ToastView?
@IBOutlet private var emailAddressTextField: UITextField!
@IBOutlet private var appleIDTextField: UITextField!
@IBOutlet private var passwordTextField: UITextField!
@IBOutlet private var signInButton: UIButton!
@IBOutlet private var appleIDBackgroundView: UIView!
@IBOutlet private var passwordBackgroundView: UIView!
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var contentStackView: UIStackView!
override func viewDidLoad()
{
super.viewDidLoad()
self.update()
}
override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()
self.signInButton.activityIndicatorView.style = .white
if !_didLayoutSubviews
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
{
self.emailAddressTextField.becomeFirstResponder()
view.clipsToBounds = true
view.layer.cornerRadius = 16
}
if UIScreen.main.isExtraCompactHeight
{
self.contentStackView.spacing = 20
}
_didLayoutSubviews = true
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: self.appleIDTextField)
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: self.passwordTextField)
self.update()
}
override func viewDidDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false
self.signInButton.isIndicatingActivity = false
self.toastView?.dismiss()
}
}
@@ -53,39 +65,25 @@ private extension AuthenticationViewController
{
if let _ = self.validate()
{
self.navigationItem.rightBarButtonItem?.isEnabled = true
self.signInButton.isEnabled = true
self.signInButton.alpha = 1.0
}
else
{
self.navigationItem.rightBarButtonItem?.isEnabled = false
self.signInButton.isEnabled = false
self.signInButton.alpha = 0.6
}
}
func validate() -> (String, String)?
{
guard
let emailAddress = self.emailAddressTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
let emailAddress = self.appleIDTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
let password = self.passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty
else { return nil }
return (emailAddress, password)
}
func authenticate(emailAddress: String, password: String, completionHandler: @escaping (Result<(ALTAccount, [ALTTeam]), Error>) -> Void)
{
ALTAppleAPI.shared.authenticate(appleID: emailAddress, password: password) { (account, error) in
switch Result(account, error)
{
case .failure(let error): completionHandler(.failure(error))
case .success(let account):
ALTAppleAPI.shared.fetchTeams(for: account) { (teams, error) in
let result = Result(teams, error).map { (account, $0) }
completionHandler(result)
}
}
}
}
}
private extension AuthenticationViewController
@@ -94,33 +92,44 @@ private extension AuthenticationViewController
{
guard let (emailAddress, password) = self.validate() else { return }
self.emailAddressTextField.resignFirstResponder()
self.appleIDTextField.resignFirstResponder()
self.passwordTextField.resignFirstResponder()
self.navigationItem.rightBarButtonItem?.isIndicatingActivity = true
self.signInButton.isIndicatingActivity = true
ALTAppleAPI.shared.authenticate(appleID: emailAddress, password: password) { (account, error) in
do
{
let account = try Result(account, error).get()
self.authenticationHandler?((account, password))
}
catch
self.authenticationHandler?(emailAddress, password) { (result) in
switch result
{
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
// Ignore
DispatchQueue.main.async {
let toastView = RSTToastView(text: NSLocalizedString("Failed to Log In", comment: ""), detailText: error.localizedDescription)
toastView.tintColor = .altPurple
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false
self.signInButton.isIndicatingActivity = false
}
case .failure(let error):
DispatchQueue.main.async {
let toastView = ToastView(text: NSLocalizedString("Failed to Log In", comment: ""), detailText: error.localizedDescription)
toastView.textLabel.textColor = .altPink
toastView.detailTextLabel.textColor = .altPink
toastView.show(in: self.navigationController?.view ?? self.view)
self.toastView = toastView
self.signInButton.isIndicatingActivity = false
}
case .success(let account, let session):
self.completionHandler?((account, session, password))
}
DispatchQueue.main.async {
self.scrollView.setContentOffset(CGPoint(x: 0, y: -self.view.safeAreaInsets.top), animated: true)
}
}
}
@IBAction func cancel()
@IBAction func cancel(_ sender: UIBarButtonItem)
{
self.authenticationHandler?(nil)
self.completionHandler?(nil)
}
}
@@ -130,7 +139,7 @@ extension AuthenticationViewController: UITextFieldDelegate
{
switch textField
{
case self.emailAddressTextField: self.passwordTextField.becomeFirstResponder()
case self.appleIDTextField: self.passwordTextField.becomeFirstResponder()
case self.passwordTextField: self.authenticate()
default: break
}
@@ -140,12 +149,21 @@ extension AuthenticationViewController: UITextFieldDelegate
return false
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool
func textFieldDidBeginEditing(_ textField: UITextField)
{
DispatchQueue.main.async {
self.update()
}
guard UIScreen.main.isExtraCompactHeight else { return }
return true
// Position all the controls within visible frame.
var contentOffset = self.scrollView.contentOffset
contentOffset.y = 44
self.scrollView.setContentOffset(contentOffset, animated: true)
}
}
extension AuthenticationViewController
{
@objc func textFieldDidChangeText(_ notification: Notification)
{
self.update()
}
}

View File

@@ -0,0 +1,54 @@
//
// InstructionsViewController.swift
// AltStore
//
// Created by Riley Testut on 9/6/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
class InstructionsViewController: UIViewController
{
var completionHandler: (() -> Void)?
var showsBottomButton: Bool = false
@IBOutlet private var contentStackView: UIStackView!
@IBOutlet private var dismissButton: UIButton!
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func viewDidLoad()
{
super.viewDidLoad()
if UIScreen.main.isExtraCompactHeight
{
self.contentStackView.layoutMargins.top = 0
self.contentStackView.layoutMargins.bottom = self.contentStackView.layoutMargins.left
}
self.dismissButton.clipsToBounds = true
self.dismissButton.layer.cornerRadius = 16
if self.showsBottomButton
{
self.navigationItem.hidesBackButton = true
}
else
{
self.dismissButton.isHidden = true
}
}
}
private extension InstructionsViewController
{
@IBAction func dismiss()
{
self.completionHandler?()
}
}

View File

@@ -0,0 +1,89 @@
//
// RefreshAltStoreViewController.swift
// AltStore
//
// Created by Riley Testut on 10/26/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltSign
import Roxas
class RefreshAltStoreViewController: UIViewController
{
var signer: ALTSigner!
var session: ALTAppleAPISession!
var completionHandler: ((Result<Void, Error>) -> Void)?
@IBOutlet private var placeholderView: RSTPlaceholderView!
override func viewDidLoad()
{
super.viewDidLoad()
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.textAlignment = .left
self.placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
self.placeholderView.detailTextLabel.text = NSLocalizedString("AltStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including AltStore.\n\nTo prevent AltStore from expiring early, please refresh the app now. AltStore will quit once refreshing is complete.", comment: "")
}
}
private extension RefreshAltStoreViewController
{
@IBAction func refreshAltStore(_ sender: PillButton)
{
guard let altStore = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext) else { return }
func refresh()
{
sender.isIndicatingActivity = true
if let progress = AppManager.shared.refreshProgress(for: altStore) ?? AppManager.shared.installationProgress(for: altStore)
{
// Cancel pending AltStore refresh so we can start a new one.
progress.cancel()
}
let group = OperationGroup()
group.signer = self.signer // Prevent us from trying to authenticate a second time.
group.session = self.session // ^
group.completionHandler = { (result) in
if let error = result.error ?? result.value?.values.compactMap({ $0.error }).first
{
DispatchQueue.main.async {
sender.progress = nil
sender.isIndicatingActivity = false
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh AltStore", comment: ""), message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: ""), style: .default, handler: { (action) in
refresh()
}))
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh Later", comment: ""), style: .cancel, handler: { (action) in
self.completionHandler?(.failure(error))
}))
self.present(alertController, animated: true, completion: nil)
}
}
else
{
self.completionHandler?(.success(()))
}
}
_ = AppManager.shared.refresh([altStore], presentingViewController: self, group: group)
sender.progress = group.progress
}
refresh()
}
@IBAction func cancel(_ sender: UIButton)
{
self.completionHandler?(.failure(OperationError.cancelled))
}
}

View File

@@ -1,156 +0,0 @@
//
// ReplaceCertificateViewController.swift
// AltStore
//
// Created by Riley Testut on 6/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltSign
import Roxas
extension ReplaceCertificateViewController
{
private enum Error: LocalizedError
{
case missingPrivateKey
case missingCertificate
var errorDescription: String? {
switch self
{
case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "")
case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "")
}
}
}
}
class ReplaceCertificateViewController: UITableViewController
{
var replacementHandler: ((ALTCertificate?) -> Void)?
var team: ALTTeam!
var certificates: [ALTCertificate] {
get {
return self.dataSource.items
}
set {
self.dataSource.items = newValue
}
}
private var selectedCertificate: ALTCertificate? {
didSet {
self.update()
}
}
private lazy var dataSource = self.makeDataSource()
override func viewDidLoad()
{
super.viewDidLoad()
self.tableView.dataSource = self.dataSource
self.update()
}
}
private extension ReplaceCertificateViewController
{
func makeDataSource() -> RSTArrayTableViewDataSource<ALTCertificate>
{
let dataSource = RSTArrayTableViewDataSource<ALTCertificate>(items: [])
dataSource.proxy = self
dataSource.cellConfigurationHandler = { [weak self] (cell, certificate, indexPath) in
cell.textLabel?.text = certificate.name
cell.accessoryType = (self?.selectedCertificate == certificate) ? .checkmark : .none
}
let placeholderView = RSTPlaceholderView(frame: .zero)
placeholderView.textLabel.text = NSLocalizedString("No Certificates", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("There are no certificates associated with this team.", comment: "")
dataSource.placeholderView = placeholderView
return dataSource
}
func update()
{
self.navigationItem.rightBarButtonItem?.isEnabled = (self.selectedCertificate != nil)
if self.isViewLoaded
{
self.tableView.reloadData()
}
}
}
private extension ReplaceCertificateViewController
{
@IBAction func replaceCertificate(_ sender: UIBarButtonItem)
{
guard let certificate = self.selectedCertificate else { return }
func replace()
{
sender.isIndicatingActivity = true
ALTAppleAPI.shared.revoke(certificate, for: self.team) { (success, error) in
let result = Result(success, error).map { certificate }
do
{
let certificate = try result.get()
self.replacementHandler?(certificate)
}
catch
{
DispatchQueue.main.async {
let toastView = RSTToastView(text: NSLocalizedString("Error Replacing Certificate", comment: ""), detailText: error.localizedDescription)
toastView.tintColor = .altPurple
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
sender.isIndicatingActivity = false
}
}
}
}
let localizedTitle = String(format: NSLocalizedString("Are you sure you want to replace %@?", comment: ""), certificate.name)
let localizedMessage = NSLocalizedString("Any AltStore apps currently installed with this certificate will need to be refreshed.", comment: "")
let localizedReplaceActionTitle = String(format: NSLocalizedString("Replace %@", comment: ""), certificate.name)
let alertController = UIAlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: localizedReplaceActionTitle, style: .destructive) { (action) in
replace()
})
alertController.addAction(.cancel)
self.present(alertController, animated: true, completion: nil)
}
@IBAction func cancel()
{
self.replacementHandler?(nil)
}
}
extension ReplaceCertificateViewController
{
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String?
{
return NSLocalizedString("You have reached the maximum number of development certificates. Please select a certificate to replace.", comment: "")
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
let certificate = self.dataSource.item(at: indexPath)
self.selectedCertificate = certificate
}
}

View File

@@ -1,141 +0,0 @@
//
// SelectTeamViewController.swift
// AltStore
//
// Created by Riley Testut on 6/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltSign
import Roxas
class SelectTeamViewController: UITableViewController
{
var selectionHandler: ((ALTTeam?) -> Void)?
var teams: [ALTTeam] {
get {
return self.dataSource.items
}
set {
self.dataSource.items = newValue
}
}
private var selectedTeam: ALTTeam? {
didSet {
self.update()
}
}
private lazy var dataSource = self.makeDataSource()
override func viewDidLoad()
{
super.viewDidLoad()
self.tableView.dataSource = self.dataSource
self.update()
}
override func viewWillDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false
}
}
private extension SelectTeamViewController
{
func makeDataSource() -> RSTArrayTableViewDataSource<ALTTeam>
{
let dataSource = RSTArrayTableViewDataSource<ALTTeam>(items: [])
dataSource.proxy = self
dataSource.cellConfigurationHandler = { [weak self] (cell, team, indexPath) in
cell.textLabel?.text = team.name
cell.detailTextLabel?.text = team.type.localizedDescription
cell.accessoryType = (self?.selectedTeam == team) ? .checkmark : .none
}
let placeholderView = RSTPlaceholderView(frame: .zero)
placeholderView.textLabel.text = NSLocalizedString("No Teams", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("You are not a member of any development teams.", comment: "")
dataSource.placeholderView = placeholderView
return dataSource
}
func update()
{
self.navigationItem.rightBarButtonItem?.isEnabled = (self.selectedTeam != nil)
if self.isViewLoaded
{
self.tableView.reloadData()
}
}
func fetchCertificates(for team: ALTTeam, completionHandler: @escaping (Result<[ALTCertificate], Error>) -> Void)
{
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificate, error) in
let result = Result(certificate, error)
completionHandler(result)
}
}
}
private extension SelectTeamViewController
{
@IBAction func chooseTeam(_ sender: UIBarButtonItem)
{
guard let team = self.selectedTeam else { return }
func choose()
{
sender.isIndicatingActivity = true
self.selectionHandler?(team)
}
if team.type == .organization
{
let localizedActionTitle = String(format: NSLocalizedString("Use %@?", comment: ""), team.name)
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to use an Organization team?", comment: ""),
message: NSLocalizedString("Doing so may affect other members of this team.", comment: ""), preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: localizedActionTitle, style: .destructive, handler: { (action) in
choose()
}))
alertController.addAction(.cancel)
self.present(alertController, animated: true, completion: nil)
}
else
{
choose()
}
}
@IBAction func cancel()
{
self.selectionHandler?(nil)
}
}
extension SelectTeamViewController
{
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String?
{
return NSLocalizedString("Select the team you would like to use to install apps.", comment: "")
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
let team = self.dataSource.item(at: indexPath)
self.selectedTeam = team
}
}

View File

@@ -1,7 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="6NO-wl-tj1">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@@ -11,15 +14,38 @@
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" name="Background"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
<tabBarItem key="tabBarItem" title="" id="RiK-sx-Kgv"/>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
<point key="canvasLocation" x="962.31884057971024" y="375"/>
</scene>
<!--Tab Bar Controller-->
<scene sceneID="9Yy-ze-Trt">
<objects>
<tabBarController automaticallyAdjustsScrollViewInsets="NO" id="6NO-wl-tj1" sceneMemberID="viewController">
<toolbarItems/>
<tabBar key="tabBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="4lc-l2-vDf">
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</tabBar>
<connections>
<segue destination="01J-lp-oVM" kind="relationship" relationship="viewControllers" id="2qH-aa-n0z"/>
</connections>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="pxX-hL-ovw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52.173913043478265" y="375"/>
</scene>
</scenes>
<resources>
<namedColor name="Background">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -1,11 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="wKh-xq-NuP">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="wKh-xq-NuP">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -21,9 +19,6 @@
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<viewLayoutGuide key="safeArea" id="sZd-sc-Bvn"/>
</view>
<connections>
<segue destination="49e-Tb-3d3" kind="presentation" identifier="finishLaunching" modalTransitionStyle="crossDissolve" id="6Ov-Kc-Van"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="vOq-mm-rY5" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
@@ -32,16 +27,17 @@
<!--Tab Bar Controller-->
<scene sceneID="yl2-sM-qoP">
<objects>
<tabBarController id="49e-Tb-3d3" sceneMemberID="viewController">
<tabBarController storyboardIdentifier="tabBarController" modalPresentationStyle="fullScreen" id="49e-Tb-3d3" customClass="TabBarController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBar key="tabBar" contentMode="scaleToFill" id="W28-zg-YXA">
<rect key="frame" x="0.0" y="975" width="768" height="49"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
</tabBar>
<connections>
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="kCE-KJ-sWv"/>
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="F8I-ea-yTZ"/>
<segue destination="MGm-Zy-ffn" kind="relationship" relationship="viewControllers" id="9m0-Rb-vjU"/>
<segue destination="kjR-gi-fgT" kind="relationship" relationship="viewControllers" id="eWy-uk-nwG"/>
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="dXz-Tu-hW8"/>
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="zii-dF-qEt"/>
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="4Nf-rL-P4c"/>
</connections>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="HuB-VB-40B" sceneMemberID="firstResponder"/>
@@ -55,12 +51,12 @@
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="CaT-1q-qnx">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="10" id="e0H-IH-rng">
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="50" minimumInteritemSpacing="10" id="e0H-IH-rng">
<size key="itemSize" width="375" height="400"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
<inset key="sectionInset" minX="0.0" minY="8" maxX="0.0" maxY="20"/>
</collectionViewFlowLayout>
<cells/>
<connections>
@@ -72,7 +68,7 @@
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1517.5999999999999" y="-1013.3433283358322"/>
<point key="canvasLocation" x="1730" y="-17"/>
</scene>
<!--App View Controller-->
<scene sceneID="TgT-LO-3Er">
@@ -84,7 +80,6 @@
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Bql-t3-Ndi">
<rect key="frame" x="47" y="238" width="85" height="35"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="j1W-Jn-HFI" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="35" height="35"/>
@@ -111,10 +106,9 @@
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" insetsLayoutMarginsFromSafeArea="NO" id="oNk-OQ-r4M">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="0.0" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<blurEffect style="light"/>
<blurEffect style="regular"/>
</visualEffectView>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" translatesAutoresizingMaskIntoConstraints="NO" id="Ci9-Iw-aR2">
<rect key="frame" x="0.0" y="0.0" width="375" height="618"/>
@@ -125,61 +119,10 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qlg-m3-lXg">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<subviews>
<visualEffectView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mgO-eN-SxQ">
<rect key="frame" x="38" y="287" width="300" height="93"/>
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="NEy-yr-cLS" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="37" y="287" width="300" height="93"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" insetsLayoutMarginsFromSafeArea="NO" id="yIo-bR-OBC">
<rect key="frame" x="0.0" y="0.0" width="300" height="93"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="LZw-eU-5SO" userLabel="App Info">
<rect key="frame" x="0.0" y="0.0" width="273" height="93"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="3Ey-6S-HJx" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="14" y="14" width="65" height="65"/>
<constraints>
<constraint firstAttribute="height" constant="65" id="AIz-49-Wuj"/>
<constraint firstAttribute="width" secondItem="3Ey-6S-HJx" secondAttribute="height" multiplier="1:1" id="GCk-a1-dDk"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="bR7-SO-m8f">
<rect key="frame" x="90" y="26.5" width="88" height="40.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dNE-IO-y3o">
<rect key="frame" x="0.0" y="0.0" width="88" height="21.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="NKT-el-rRF">
<rect key="frame" x="0.0" y="23.5" width="88" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mgB-Gs-bik" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="189" y="31" width="72" 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" constant="72" id="j44-T1-0dc"/>
<constraint firstAttribute="height" constant="31" id="qY2-Ng-KJy"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="FREE"/>
<connections>
<action selector="performAppAction:" destination="0V6-N4-hTO" eventType="primaryActionTriggered" id="wPd-Kn-6fI"/>
</connections>
</button>
</subviews>
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
</stackView>
</subviews>
</view>
<blurEffect style="extraLight"/>
</visualEffectView>
</view>
<containerView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="FIv-I9-5uW">
<rect key="frame" x="0.0" y="450" width="375" height="217"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
@@ -194,22 +137,35 @@
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mkD-3C-WMV">
<visualEffectView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="JP7-6F-CoG">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<state key="normal" image="Back"/>
<connections>
<action selector="popViewController:" destination="0V6-N4-hTO" eventType="primaryActionTriggered" id="F6Z-xz-qCk"/>
</connections>
</button>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" insetsLayoutMarginsFromSafeArea="NO" id="UJ5-ia-PVA">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mkD-3C-WMV">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<state key="normal" image="Back"/>
<connections>
<action selector="popViewController:" destination="0V6-N4-hTO" eventType="primaryActionTriggered" id="F6Z-xz-qCk"/>
</connections>
</button>
</subviews>
</view>
<vibrancyEffect style="fill">
<blurEffect style="prominent"/>
</vibrancyEffect>
</visualEffectView>
</subviews>
</view>
<blurEffect style="extraLight"/>
<blurEffect style="prominent"/>
</visualEffectView>
</subviews>
</view>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<constraints>
<constraint firstItem="Ci9-Iw-aR2" firstAttribute="top" secondItem="0cR-li-tCB" secondAttribute="top" id="015-fz-v3B"/>
<constraint firstAttribute="top" secondItem="Qlg-m3-lXg" secondAttribute="top" id="8tb-sY-MOu"/>
@@ -230,7 +186,7 @@
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" constant="31" id="S2r-fI-PQB"/>
<constraint firstAttribute="width" constant="72" id="Xtq-UG-h3b"/>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="72" id="Xtq-UG-h3b"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="FREE"/>
@@ -241,17 +197,12 @@
</barButtonItem>
</navigationItem>
<connections>
<outlet property="appIconImageView" destination="3Ey-6S-HJx" id="5FB-mn-E29"/>
<outlet property="backButton" destination="mkD-3C-WMV" id="3m8-P7-yvT"/>
<outlet property="backButtonContainerView" destination="tUK-0J-07U" id="POZ-dP-f12"/>
<outlet property="backgroundAppIconImageView" destination="CUB-SN-zdM" id="dFx-py-yMm"/>
<outlet property="backgroundBlurView" destination="8Tg-wk-r0u" id="B8c-ng-nI5"/>
<outlet property="bannerView" destination="NEy-yr-cLS" id="MTr-hK-LIR"/>
<outlet property="contentView" destination="Qlg-m3-lXg" id="JhH-hh-vBN"/>
<outlet property="developerLabel" destination="NKT-el-rRF" id="GUc-jy-kvv"/>
<outlet property="downloadButton" destination="mgB-Gs-bik" id="x95-gu-NBy"/>
<outlet property="headerContentView" destination="LZw-eU-5SO" id="hk1-xG-2kJ"/>
<outlet property="headerView" destination="mgO-eN-SxQ" id="iIi-D7-XRt"/>
<outlet property="nameLabel" destination="dNE-IO-y3o" id="tp1-IT-ByH"/>
<outlet property="navigationBarAppIconImageView" destination="j1W-Jn-HFI" id="2YU-ka-w9R"/>
<outlet property="navigationBarAppNameLabel" destination="DTD-1Y-76c" id="z9z-pp-dC4"/>
<outlet property="navigationBarDownloadButton" destination="grk-xM-YWA" id="Yrg-S0-tIM"/>
@@ -261,7 +212,7 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="C9o-C3-sMK" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2312.8000000000002" y="-1013.3433283358322"/>
<point key="canvasLocation" x="2525.5999999999999" y="-17.541229385307346"/>
</scene>
<!--App-->
<scene sceneID="CgX-7h-sRI">
@@ -270,12 +221,12 @@
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" contentInsetAdjustmentBehavior="never" dataMode="static" style="plain" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" contentViewInsetsToSafeArea="NO" id="w5c-Q3-FcU">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<sections>
<tableViewSection id="rfR-32-T0h">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="57" id="xef-ko-Qp1">
<rect key="frame" x="0.0" y="0.0" width="375" height="57"/>
<rect key="frame" x="0.0" y="28" width="375" height="57"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="xef-ko-Qp1" id="8PX-jQ-nHd">
<rect key="frame" x="0.0" y="0.0" width="375" height="57"/>
@@ -295,10 +246,11 @@
<constraint firstItem="BsL-O2-UjD" firstAttribute="top" secondItem="8PX-jQ-nHd" secondAttribute="top" constant="20" id="dRc-WY-Jbk"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
<rect key="frame" x="0.0" y="57" width="375" height="44"/>
<rect key="frame" x="0.0" y="85" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nI6-wC-H2d" id="Z4y-vb-Z4Q">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
@@ -306,7 +258,7 @@
<subviews>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="ppk-lL-at8">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2">
<size key="itemSize" width="189" height="406"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
@@ -332,10 +284,11 @@
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="101" width="375" height="44"/>
<rect key="frame" x="0.0" y="129" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EL5-UC-RIw" id="D1G-nK-G0Z">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
@@ -343,7 +296,7 @@
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="20" y="20" width="335" height="34"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
@@ -355,10 +308,11 @@
<constraint firstAttribute="trailing" secondItem="Pyt-8D-BZA" secondAttribute="trailing" constant="20" id="Wq4-Ql-wvN"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="145" width="375" height="44"/>
<rect key="frame" x="0.0" y="173" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="47M-El-a4G" id="f9D-OR-oGE">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
@@ -408,7 +362,7 @@
</stackView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="20" y="16" width="335" height="0.0"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
@@ -423,10 +377,11 @@
<constraint firstAttribute="bottom" secondItem="n9R-39-Glq" secondAttribute="bottom" priority="999" id="Zol-57-Lbq"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="149" id="nM7-vJ-W8b">
<rect key="frame" x="0.0" y="189" width="375" height="149"/>
<rect key="frame" x="0.0" y="217" width="375" height="149"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nM7-vJ-W8b" id="cQ2-Jd-pRK">
<rect key="frame" x="0.0" y="0.0" width="375" height="149"/>
@@ -443,7 +398,7 @@
</label>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="r8T-dj-wQX">
<rect key="frame" x="0.0" y="41" width="375" height="88"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<constraints>
<constraint firstAttribute="height" constant="88" id="6Lk-OO-MsA"/>
</constraints>
@@ -513,6 +468,7 @@ World</string>
<constraint firstItem="Jvb-r8-XrY" firstAttribute="top" secondItem="cQ2-Jd-pRK" secondAttribute="top" id="Urh-Qr-vrS"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
</cells>
@@ -538,7 +494,7 @@ World</string>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dhh-ZN-LoG" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3088.8000000000002" y="-1014.2428785607198"/>
<point key="canvasLocation" x="3302" y="-18"/>
</scene>
<!--Permission Popover View Controller-->
<scene sceneID="24j-EJ-G4e">
@@ -549,34 +505,32 @@ World</string>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="xnC-tS-ZdV">
<rect key="frame" x="169" y="90" width="37.5" height="37"/>
<rect key="frame" x="20" y="10" width="335" height="197"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4fh-lO-rAn">
<rect key="frame" x="0.0" y="0.0" width="37.5" height="17"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4fh-lO-rAn">
<rect key="frame" x="0.0" y="0.0" width="335" height="17"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="300" translatesAutoresizingMaskIntoConstraints="NO" id="ErG-8A-uqY">
<rect key="frame" x="0.0" y="21" width="37.5" height="16"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="300" translatesAutoresizingMaskIntoConstraints="NO" id="ErG-8A-uqY">
<rect key="frame" x="0.0" y="21" width="335" height="176"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/>
</stackView>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" systemColor="tertiarySystemBackgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="IgU-aM-YrX" secondAttribute="leadingMargin" id="LO8-Au-SYF"/>
<constraint firstAttribute="bottomMargin" relation="greaterThanOrEqual" secondItem="xnC-tS-ZdV" secondAttribute="bottom" id="NZ9-iG-E10"/>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="centerX" secondItem="IgU-aM-YrX" secondAttribute="centerX" id="QAB-qN-HdL"/>
<constraint firstAttribute="trailingMargin" relation="greaterThanOrEqual" secondItem="xnC-tS-ZdV" secondAttribute="trailing" id="ZkD-tb-mBf"/>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="top" relation="greaterThanOrEqual" secondItem="IgU-aM-YrX" secondAttribute="topMargin" id="oKq-9e-DtW"/>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="centerY" secondItem="IgU-aM-YrX" secondAttribute="centerY" id="qCU-ye-fSf"/>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="leading" secondItem="c7x-ee-3HH" secondAttribute="leading" constant="20" id="LO8-Au-SYF"/>
<constraint firstItem="c7x-ee-3HH" firstAttribute="bottom" secondItem="xnC-tS-ZdV" secondAttribute="bottom" constant="10" id="NZ9-iG-E10"/>
<constraint firstItem="c7x-ee-3HH" firstAttribute="trailing" secondItem="xnC-tS-ZdV" secondAttribute="trailing" constant="20" id="ZkD-tb-mBf"/>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="top" secondItem="c7x-ee-3HH" secondAttribute="top" constant="10" id="oKq-9e-DtW"/>
</constraints>
<edgeInsets key="layoutMargins" top="10" left="20" bottom="10" right="20"/>
<viewLayoutGuide key="safeArea" id="wu0-44-ei8"/>
<viewLayoutGuide key="safeArea" id="c7x-ee-3HH"/>
</view>
<connections>
<outlet property="descriptionLabel" destination="ErG-8A-uqY" id="iuN-kE-IEm"/>
@@ -585,252 +539,54 @@ World</string>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="7Tu-x9-xBb" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3908" y="-1484"/>
<point key="canvasLocation" x="4257" y="-412"/>
</scene>
<!--Settings-->
<scene sceneID="GaO-Ug-BdZ">
<scene sceneID="KlD-j0-ROn">
<objects>
<navigationController id="MGm-Zy-ffn" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Settings" image="Settings" id="8Ic-ki-txH"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="rzJ-pZ-611">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="VBC-qD-V1a" kind="relationship" relationship="rootViewController" id="tgI-RK-57z"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cM4-hZ-uHG" userLabel="First Responder" sceneMemberID="firstResponder"/>
<viewControllerPlaceholder storyboardName="Settings" id="p3d-dP-Swg" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Settings" image="Settings" id="OZm-Le-7oJ"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="HgE-PD-dC2" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="750" y="515"/>
<point key="canvasLocation" x="962" y="1197"/>
</scene>
<!--Settings-->
<scene sceneID="Xdi-2V-rwM">
<!--News-->
<scene sceneID="bqw-wB-hyB">
<objects>
<tableViewController id="VBC-qD-V1a" customClass="SettingsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="dOC-Gz-Ieu">
<collectionViewController id="3sa-FZ-PTg" customClass="NewsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" id="736-lq-Aef">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<sections>
<tableViewSection headerTitle="Account" id="nOs-a4-lBS">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="4Qf-b3-Kd1" detailTextLabel="zvb-TJ-uGW" style="IBUITableViewCellStyleValue1" id="HgQ-vv-9nH">
<rect key="frame" x="0.0" y="55.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="HgQ-vv-9nH" id="SSv-nz-f4V">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="4Qf-b3-Kd1">
<rect key="frame" x="16" y="12" width="45" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Riley Testut (iOS Developer)" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="zvb-TJ-uGW">
<rect key="frame" x="144.5" y="12" width="214.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="1UL-2f-ayi" detailTextLabel="2YH-D5-AnU" style="IBUITableViewCellStyleValue1" id="7cR-Qb-5GU">
<rect key="frame" x="0.0" y="99.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="7cR-Qb-5GU" id="iPP-TB-jnD">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Email" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="1UL-2f-ayi">
<rect key="frame" x="16" y="12" width="41" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="riley@rileytestut.com" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="2YH-D5-AnU">
<rect key="frame" x="198" y="12" width="161" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Team" id="xqO-qN-967">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" textLabel="ktC-F0-e9P" detailTextLabel="GtD-Jo-ONK" style="IBUITableViewCellStyleSubtitle" id="itp-Ya-UBR">
<rect key="frame" x="0.0" y="199.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="itp-Ya-UBR" id="w1A-z5-P4W">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="ktC-F0-e9P">
<rect key="frame" x="16" y="5" width="33.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Subtitle" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="GtD-Jo-ONK">
<rect key="frame" x="16" y="25.5" width="44" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Debug" id="K7R-6x-gHl">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="nQj-qq-PmI" style="IBUITableViewCellStyleDefault" id="8M3-mu-gRd">
<rect key="frame" x="0.0" y="299.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="8M3-mu-gRd" id="6TQ-nF-Rkl">
<rect key="frame" x="0.0" y="0.0" width="341" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Background Refresh Attempts" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="nQj-qq-PmI">
<rect key="frame" x="16" y="0.0" width="324" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="RUB-gO-oQ6" kind="show" id="tkT-F7-PA4"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection footerTitle="" id="Yg2-vc-vLQ">
<cells/>
</tableViewSection>
</sections>
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="40" minimumInteritemSpacing="40" id="63d-78-Y24">
<size key="itemSize" width="375" height="300"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="40" maxX="0.0" maxY="13"/>
</collectionViewFlowLayout>
<cells/>
<connections>
<outlet property="dataSource" destination="VBC-qD-V1a" id="1Xd-SN-tww"/>
<outlet property="delegate" destination="VBC-qD-V1a" id="KEk-wr-hab"/>
<outlet property="dataSource" destination="3sa-FZ-PTg" id="80N-Sr-Foq"/>
<outlet property="delegate" destination="3sa-FZ-PTg" id="9fB-sR-8Xt"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Settings" id="Mtw-26-mVI">
<barButtonItem key="rightBarButtonItem" title="Sign Out" id="0wM-zj-gVA">
<color key="tintColor" red="1" green="0.14901960780000001" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<connections>
<action selector="signOut:" destination="VBC-qD-V1a" id="X7d-Qp-VWw"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="accountEmailLabel" destination="2YH-D5-AnU" id="fyD-e7-ygs"/>
<outlet property="accountNameLabel" destination="zvb-TJ-uGW" id="mCh-p8-qCs"/>
<outlet property="teamNameLabel" destination="ktC-F0-e9P" id="2Zg-sh-2mY"/>
<outlet property="teamTypeLabel" destination="GtD-Jo-ONK" id="Jzp-2K-Bjk"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="BE4-68-0PU" userLabel="First Responder" sceneMemberID="firstResponder"/>
</collectionView>
<navigationItem key="navigationItem" title="News" id="ZxL-Ws-lJO"/>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="YS7-2X-joz" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1518" y="515"/>
</scene>
<!--Refresh Attempts-->
<scene sceneID="tCt-AY-k3Z">
<objects>
<tableViewController id="RUB-gO-oQ6" customClass="RefreshAttemptsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="g2y-h0-0xu">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" id="bBs-eV-Rlj" customClass="RefreshAttemptTableViewCell">
<rect key="frame" x="0.0" y="28" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="bBs-eV-Rlj" id="YfI-8z-wCv">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="SFD-t6-Qk3">
<rect key="frame" x="16" y="11" width="343" height="22"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="gQk-PG-cjg">
<rect key="frame" x="0.0" y="0.0" width="343" height="17"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Success" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CZj-FM-9Qv">
<rect key="frame" x="0.0" y="0.0" width="67.5" height="17"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<color key="textColor" cocoaTouchSystemColor="darkTextColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Date" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0Md-jd-XXe">
<rect key="frame" x="312.5" y="0.0" width="30.5" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Could not connect to AltServer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Pwo-OM-Gm3">
<rect key="frame" x="0.0" y="21" width="343" height="1"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="SFD-t6-Qk3" firstAttribute="leading" secondItem="YfI-8z-wCv" secondAttribute="leadingMargin" id="ehs-Sf-kyZ"/>
<constraint firstAttribute="trailingMargin" secondItem="SFD-t6-Qk3" secondAttribute="trailing" id="g4i-l8-aln"/>
<constraint firstAttribute="bottomMargin" secondItem="SFD-t6-Qk3" secondAttribute="bottom" id="h38-79-xRT"/>
<constraint firstItem="SFD-t6-Qk3" firstAttribute="top" secondItem="YfI-8z-wCv" secondAttribute="topMargin" id="mCE-eA-UKd"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="dateLabel" destination="0Md-jd-XXe" id="znM-W1-cZi"/>
<outlet property="errorDescriptionLabel" destination="Pwo-OM-Gm3" id="3zi-gX-I5t"/>
<outlet property="successLabel" destination="CZj-FM-9Qv" id="vtL-iu-aHR"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="RUB-gO-oQ6" id="vqw-LK-wuY"/>
<outlet property="delegate" destination="RUB-gO-oQ6" id="9uY-h3-7qR"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Refresh Attempts" largeTitleDisplayMode="never" id="jO4-kG-Alq">
<barButtonItem key="rightBarButtonItem" title="Sign Out" id="9Pj-GZ-Rra">
<color key="tintColor" red="1" green="0.14901960780000001" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<connections>
<action selector="signOut:" destination="VBC-qD-V1a" id="m55-18-kgT"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Wk8-xA-fxp" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2313" y="515"/>
<point key="canvasLocation" x="1730" y="-752"/>
</scene>
<!--Browse-->
<scene sceneID="VHa-uP-bFU">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="faz-B4-Sub" sceneMemberID="viewController">
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="faz-B4-Sub" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Browse" image="Browse" id="Uwh-Bg-Ymq"/>
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="dIv-qd-9L5" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<color key="tintColor" name="Green"/>
<color key="tintColor" name="Primary"/>
</navigationBar>
<nil name="viewControllers"/>
<connections>
@@ -839,18 +595,18 @@ World</string>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="OkH-49-O0J" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="750" y="-1013"/>
<point key="canvasLocation" x="962" y="-17"/>
</scene>
<!--My Apps-->
<scene sceneID="nhh-BJ-XiT">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="3Ew-ox-i4n" sceneMemberID="viewController">
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="3Ew-ox-i4n" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="My Apps" image="MyApps" id="4gT-9u-k7y">
<color key="badgeColor" name="Green"/>
<color key="badgeColor" name="Primary"/>
</tabBarItem>
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="CzO-Kt-BlZ" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<nil name="viewControllers"/>
@@ -860,7 +616,7 @@ World</string>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="9Nj-f6-CAf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="749.60000000000002" y="-279.31034482758622"/>
<point key="canvasLocation" x="962" y="717"/>
</scene>
<!--My Apps-->
<scene sceneID="EC8-Sf-AF9">
@@ -869,7 +625,7 @@ World</string>
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" id="Jrp-gi-4Df">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="SB5-U0-jyy">
<size key="itemSize" width="375" height="60"/>
<size key="headerReferenceSize" width="50" height="50"/>
@@ -877,95 +633,94 @@ World</string>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="AppCell" id="kMp-ym-2yu" customClass="InstalledAppCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="AppCell" id="kMp-ym-2yu" customClass="InstalledAppCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="50" width="375" height="60"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d6d-uV-GFi" userLabel="App Info">
<rect key="frame" x="20" y="0.0" width="335" height="60"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="H12-ip-Bbl" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="60" height="60"/>
<constraints>
<constraint firstAttribute="height" constant="60" id="SOy-Xe-y2x"/>
<constraint firstAttribute="width" secondItem="H12-ip-Bbl" secondAttribute="height" multiplier="1:1" id="ZIR-f8-Jc4"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="100" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="7iy-Zp-LEj">
<rect key="frame" x="71" y="12" width="203" height="36"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Short" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Nhl-6I-9gW">
<rect key="frame" x="0.0" y="0.0" width="203" height="18"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Hp4-uP-55T">
<rect key="frame" x="0.0" y="20" width="203" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="1000" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dh4-fU-DFx" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="285" y="15.5" width="50" height="29"/>
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="7 DAYS"/>
</button>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Expires in" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4Kc-4f-KYr">
<rect key="frame" x="306.5" y="0.5" width="47" height="12"/>
<fontDescription key="fontDescription" type="system" pointSize="10"/>
<color key="textColor" red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.45000000000000001" colorSpace="calibratedRGB"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mos-e4-dQ7" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="8" y="0.0" width="359" height="60"/>
<accessibility key="accessibilityConfiguration">
<bool key="isElement" value="YES"/>
</accessibility>
</view>
</subviews>
</view>
<constraints>
<constraint firstItem="4Kc-4f-KYr" firstAttribute="centerX" secondItem="dh4-fU-DFx" secondAttribute="centerX" id="9Uf-Qu-bhZ"/>
<constraint firstItem="d6d-uV-GFi" firstAttribute="leading" secondItem="kMp-ym-2yu" secondAttribute="leading" constant="20" id="fV7-0C-Hop"/>
<constraint firstItem="d6d-uV-GFi" firstAttribute="top" secondItem="kMp-ym-2yu" secondAttribute="top" id="rCI-7z-0mR"/>
<constraint firstItem="dh4-fU-DFx" firstAttribute="top" secondItem="4Kc-4f-KYr" secondAttribute="bottom" constant="3" id="rmM-9v-G5C"/>
<constraint firstAttribute="trailing" secondItem="d6d-uV-GFi" secondAttribute="trailing" constant="20" id="s7H-ei-AEn"/>
<constraint firstAttribute="trailingMargin" secondItem="mos-e4-dQ7" secondAttribute="trailing" id="TKN-0r-5ON"/>
<constraint firstItem="mos-e4-dQ7" firstAttribute="top" secondItem="kMp-ym-2yu" secondAttribute="top" id="TUp-Xe-CHP"/>
<constraint firstAttribute="bottom" secondItem="mos-e4-dQ7" secondAttribute="bottom" id="gO1-mC-cTz"/>
<constraint firstItem="mos-e4-dQ7" firstAttribute="leading" secondItem="kMp-ym-2yu" secondAttribute="leadingMargin" id="i49-Gc-w7s"/>
</constraints>
<connections>
<outlet property="appIconImageView" destination="H12-ip-Bbl" id="61F-4i-4Q3"/>
<outlet property="developerLabel" destination="Hp4-uP-55T" id="Cqx-3O-knq"/>
<outlet property="nameLabel" destination="Nhl-6I-9gW" id="lzd-pp-PEQ"/>
<outlet property="refreshButton" destination="dh4-fU-DFx" id="KWX-9y-2w8"/>
<outlet property="bannerView" destination="mos-e4-dQ7" id="z01-3x-alE"/>
<segue destination="0V6-N4-hTO" kind="show" identifier="showApp" id="cnd-KK-o60">
<segue key="commit" inheritsFrom="parent" id="YdR-Ct-SlK"/>
<segue key="preview" inheritsFrom="commit" id="GSg-SY-gai"/>
</segue>
</connections>
</collectionViewCell>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="NoUpdatesCell" id="h0f-XI-UA5">
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="NoUpdatesCell" id="h0f-XI-UA5" customClass="NoUpdatesCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="125" width="375" height="60"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No Updates Available" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z04-yg-x1t">
<rect key="frame" x="104" y="20" width="167" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
<color key="textColor" name="Green"/>
<nil key="highlightedColor"/>
</label>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7iO-O4-Mr9">
<rect key="frame" x="8" y="0.0" width="359" height="60"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="d2X-wj-EhR">
<rect key="frame" x="0.0" y="0.0" width="359" height="60"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="zAy-K2-jA4">
<rect key="frame" x="0.0" y="0.0" width="359" height="60"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="F8U-ab-fOM">
<rect key="frame" x="0.0" y="0.0" width="359" height="60"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No Updates Available" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z04-yg-x1t">
<rect key="frame" x="96" y="20" width="167" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
<color key="textColor" name="Primary"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="z04-yg-x1t" firstAttribute="centerY" secondItem="F8U-ab-fOM" secondAttribute="centerY" id="9w9-Z0-jZl"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="z04-yg-x1t" secondAttribute="bottom" constant="10" id="IWL-Ei-QC2"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="top" relation="greaterThanOrEqual" secondItem="F8U-ab-fOM" secondAttribute="top" constant="10" id="fLp-au-PLf"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="centerX" secondItem="F8U-ab-fOM" secondAttribute="centerX" id="fiy-Zt-GmB"/>
</constraints>
</view>
<vibrancyEffect style="secondaryLabel">
<blurEffect style="systemChromeMaterial"/>
</vibrancyEffect>
</visualEffectView>
</subviews>
<color key="backgroundColor" name="BlurTint"/>
<constraints>
<constraint firstItem="zAy-K2-jA4" firstAttribute="top" secondItem="d2X-wj-EhR" secondAttribute="top" id="3GP-KH-ao8"/>
<constraint firstAttribute="trailing" secondItem="zAy-K2-jA4" secondAttribute="trailing" id="H29-aK-27e"/>
<constraint firstAttribute="bottom" secondItem="zAy-K2-jA4" secondAttribute="bottom" id="Ha4-Od-VHk"/>
<constraint firstItem="zAy-K2-jA4" firstAttribute="leading" secondItem="d2X-wj-EhR" secondAttribute="leading" id="rmG-C1-DoK"/>
</constraints>
</view>
<blurEffect style="systemChromeMaterial"/>
</visualEffectView>
</subviews>
</view>
<constraints>
<constraint firstItem="z04-yg-x1t" firstAttribute="centerY" secondItem="h0f-XI-UA5" secondAttribute="centerY" id="3dw-fe-ACP"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="centerX" secondItem="h0f-XI-UA5" secondAttribute="centerX" id="AIh-kx-SmK"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="top" relation="greaterThanOrEqual" secondItem="h0f-XI-UA5" secondAttribute="top" constant="10" id="QwS-y9-ahl"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="z04-yg-x1t" secondAttribute="bottom" constant="10" id="uQI-7x-E3b"/>
<constraint firstItem="7iO-O4-Mr9" firstAttribute="leading" secondItem="h0f-XI-UA5" secondAttribute="leadingMargin" id="4Kn-tp-E7l"/>
<constraint firstItem="7iO-O4-Mr9" firstAttribute="top" secondItem="h0f-XI-UA5" secondAttribute="top" id="Cxd-IB-cmI"/>
<constraint firstAttribute="bottom" secondItem="7iO-O4-Mr9" secondAttribute="bottom" id="Xk3-SQ-iHD"/>
<constraint firstAttribute="trailingMargin" secondItem="7iO-O4-Mr9" secondAttribute="trailing" id="ZwB-wX-siW"/>
</constraints>
<connections>
<outlet property="blurView" destination="7iO-O4-Mr9" id="kQ4-9N-nnv"/>
</connections>
</collectionViewCell>
</cells>
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="InstalledAppsHeader" id="Crb-NU-1Ye" customClass="InstalledAppsCollectionHeaderView" customModule="AltStore" customModuleProvider="target">
@@ -973,7 +728,7 @@ World</string>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Installed" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="BDU-hM-rro">
<rect key="frame" x="20" y="21" width="97" height="29"/>
<rect key="frame" x="20" y="21" width="96.5" height="29"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
@@ -1007,23 +762,53 @@ World</string>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<segue destination="0V6-N4-hTO" kind="show" identifier="showUpdate" id="dzt-2e-VM9"/>
</connections>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="kiO-UO-esV" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1517.5999999999999" y="-279.31034482758622"/>
<point key="canvasLocation" x="1728.8" y="716.49175412293857"/>
</scene>
<!--News-->
<scene sceneID="BV8-6J-nIv">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="kjR-gi-fgT" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="News" image="News" id="fVN-ed-uO1"/>
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="525-jF-uDK" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
</navigationBar>
<nil name="viewControllers"/>
<connections>
<segue destination="3sa-FZ-PTg" kind="relationship" relationship="rootViewController" id="Dcj-St-vt5"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iUr-Sd-9ER" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="962" y="-752"/>
</scene>
</scenes>
<resources>
<image name="Back" width="18" height="18"/>
<image name="Browse" width="19.5" height="20.5"/>
<image name="MyApps" width="28" height="24"/>
<image name="Settings" width="21" height="21"/>
<namedColor name="Green">
<color red="0.22352941176470589" green="0.49411764705882355" blue="0.396078431372549" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<image name="Browse" width="20" height="20"/>
<image name="MyApps" width="20" height="20"/>
<image name="News" width="19" height="20"/>
<image name="Settings" width="20" height="20"/>
<namedColor name="Background">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="BlurTint">
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="Primary">
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
<inferredMetricsTieBreakers>
<segue reference="cnd-KK-o60"/>
<segue reference="dzt-2e-VM9"/>
</inferredMetricsTieBreakers>
<color key="tintColor" name="Green"/>
<color key="tintColor" name="Primary"/>
</document>

View File

@@ -10,80 +10,76 @@ import UIKit
import Roxas
import Nuke
@objc class BrowseCollectionViewCell: UICollectionViewCell
{
var imageNames: [String] = [] {
var imageURLs: [URL] = [] {
didSet {
self.dataSource.items = self.imageNames.map { $0 as NSString }
self.dataSource.items = self.imageURLs as [NSURL]
}
}
private lazy var dataSource = self.makeDataSource()
@IBOutlet var nameLabel: UILabel!
@IBOutlet var developerLabel: UILabel!
@IBOutlet var appIconImageView: UIImageView!
@IBOutlet var actionButton: PillButton!
@IBOutlet var bannerView: AppBannerView!
@IBOutlet var subtitleLabel: UILabel!
@IBOutlet var screenshotsCollectionView: UICollectionView!
@IBOutlet private var screenshotsContentView: UIView!
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.preservesSuperviewLayoutMargins = true
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷.
self.screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.screenshotsCollectionView.delegate = self
self.screenshotsCollectionView.dataSource = self.dataSource
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
self.screenshotsContentView.layer.cornerRadius = 20
self.screenshotsContentView.layer.masksToBounds = true
self.update()
}
override func tintColorDidChange()
{
super.tintColorDidChange()
self.update()
}
}
private extension BrowseCollectionViewCell
{
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSString, UIImage>
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
{
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSString, UIImage>(items: [])
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (imageName, indexPath, completion) in
return BlockOperation {
let image = UIImage(named: imageName as String)
completion(image, nil)
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL as URL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
private func update()
{
self.subtitleLabel.textColor = self.tintColor
self.screenshotsContentView.backgroundColor = self.tintColor.withAlphaComponent(0.1)
}
}
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout

View File

@@ -1,119 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" 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="14490.49"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="400"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="400"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="Y3g-Md-6xH" userLabel="App Info">
<rect key="frame" x="20" y="20" width="335" height="79"/>
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="5gU-g3-Fsy">
<rect key="frame" x="16" y="0.0" width="343" height="369"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="F2j-pX-09A" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="7" width="65" height="65"/>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ziA-mP-AY2" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="88"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="width" secondItem="F2j-pX-09A" secondAttribute="height" multiplier="1:1" id="c2j-8O-Diw"/>
<constraint firstAttribute="height" constant="65" id="ufl-3d-nkT"/>
<constraint firstAttribute="height" constant="88" id="z3D-cI-jhp"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="zkp-KH-OyV">
<rect key="frame" x="76" y="21" width="176" height="37"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xni-8I-ewW">
<rect key="frame" x="0.0" y="0.0" width="176" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="B5S-HI-tWJ">
<rect key="frame" x="0.0" y="22.5" width="176" height="14.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="DeC-Y2-fvR" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="263" y="24" width="72" 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" constant="72" id="X7D-DN-WnD"/>
<constraint firstAttribute="height" constant="31" id="svo-Sc-wpR"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="OPEN"/>
</button>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
<rect key="frame" x="0.0" y="103" width="343" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
<rect key="frame" x="0.0" y="135" width="343" height="234"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="15" id="jH9-Jo-IHA">
<size key="itemSize" width="120" height="213"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="8" minY="0.0" maxX="8" maxY="0.0"/>
</collectionViewFlowLayout>
<cells/>
</collectionView>
</subviews>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="w1r-LJ-TDs" userLabel="Screenshots">
<rect key="frame" x="15" y="114" width="345" height="266"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="hRR-84-Owd">
<rect key="frame" x="0.0" y="0.0" width="345" height="266"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
<rect key="frame" x="20" y="15" width="305" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" red="1" green="0.14901960780000001" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
<rect key="frame" x="20" y="47" width="305" height="185"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="10" id="jH9-Jo-IHA">
<size key="itemSize" width="120" height="213"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells/>
</collectionView>
</subviews>
<edgeInsets key="layoutMargins" top="15" left="20" bottom="20" right="20"/>
</stackView>
</subviews>
<color key="backgroundColor" red="1" green="0.14901960780000001" blue="0.0" alpha="0.050000000000000003" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="hRR-84-Owd" firstAttribute="leading" secondItem="w1r-LJ-TDs" secondAttribute="leading" id="3us-zR-peW"/>
<constraint firstItem="hRR-84-Owd" firstAttribute="top" secondItem="w1r-LJ-TDs" secondAttribute="top" id="HWW-aS-Scd"/>
<constraint firstAttribute="trailing" secondItem="hRR-84-Owd" secondAttribute="trailing" id="lbU-TC-jhJ"/>
<constraint firstAttribute="bottom" secondItem="hRR-84-Owd" secondAttribute="bottom" id="nOI-Qj-lbm"/>
</constraints>
</view>
</subviews>
</view>
<constraints>
<constraint firstAttribute="trailing" secondItem="w1r-LJ-TDs" secondAttribute="trailing" constant="15" id="4ns-Zq-D4j"/>
<constraint firstItem="w1r-LJ-TDs" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leading" constant="15" id="G1K-up-08u"/>
<constraint firstAttribute="bottom" secondItem="w1r-LJ-TDs" secondAttribute="bottom" constant="20" id="Kk0-dF-4OW"/>
<constraint firstItem="Y3g-Md-6xH" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" constant="20" id="PRR-aX-AiM"/>
<constraint firstAttribute="trailing" secondItem="Y3g-Md-6xH" secondAttribute="trailing" constant="20" id="g1Q-lg-I9O"/>
<constraint firstItem="w1r-LJ-TDs" firstAttribute="top" secondItem="Y3g-Md-6xH" secondAttribute="bottom" constant="15" id="i9W-bl-J9R"/>
<constraint firstItem="Y3g-Md-6xH" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leading" constant="20" id="j6L-IY-ALs"/>
<constraint firstItem="5gU-g3-Fsy" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" id="DnT-vq-BOc"/>
<constraint firstItem="5gU-g3-Fsy" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leadingMargin" id="YPy-xL-iUn"/>
<constraint firstAttribute="bottom" secondItem="5gU-g3-Fsy" secondAttribute="bottom" id="gRu-Hz-CNL"/>
<constraint firstAttribute="trailingMargin" secondItem="5gU-g3-Fsy" secondAttribute="trailing" id="vf4-ql-4Vq"/>
</constraints>
<viewLayoutGuide key="safeArea" id="btu-iP-81i"/>
<connections>
<outlet property="actionButton" destination="DeC-Y2-fvR" id="VDk-4D-STy"/>
<outlet property="appIconImageView" destination="F2j-pX-09A" id="COe-74-adn"/>
<outlet property="developerLabel" destination="B5S-HI-tWJ" id="QGh-1g-fFv"/>
<outlet property="nameLabel" destination="xni-8I-ewW" id="V56-ZT-vFa"/>
<outlet property="bannerView" destination="ziA-mP-AY2" id="yxo-ar-Cha"/>
<outlet property="screenshotsCollectionView" destination="RFs-qp-Ca4" id="xfi-AN-l17"/>
<outlet property="screenshotsContentView" destination="w1r-LJ-TDs" id="iWJ-52-rbA"/>
<outlet property="subtitleLabel" destination="Imx-Le-bcy" id="JVW-ZZ-51O"/>
</connections>
<point key="canvasLocation" x="136.95652173913044" y="152.67857142857142"/>
</collectionViewCell>
</objects>
</document>

View File

@@ -10,12 +10,21 @@ import UIKit
import Roxas
import Nuke
class BrowseViewController: UICollectionViewController
{
private lazy var dataSource = self.makeDataSource()
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
private var loadingState: LoadingState = .loading {
didSet {
self.update()
}
}
private var cachedItemSizes = [String: CGSize]()
override func viewDidLoad()
@@ -30,6 +39,8 @@ class BrowseViewController: UICollectionViewController
self.collectionView.prefetchDataSource = self.dataSource
self.registerForPreviewing(with: self, sourceView: self.collectionView)
self.update()
}
override func viewWillAppear(_ animated: Bool)
@@ -37,18 +48,7 @@ class BrowseViewController: UICollectionViewController
super.viewWillAppear(animated)
self.fetchSource()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
guard segue.identifier == "showApp" else { return }
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return }
let app = self.dataSource.item(at: indexPath)
let appViewController = segue.destination as! AppViewController
appViewController.app = app
self.updateDataSource()
}
}
@@ -72,58 +72,154 @@ private extension BrowseViewController
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
let cell = cell as! BrowseCollectionViewCell
cell.nameLabel.text = app.name
cell.developerLabel.text = app.developerName
cell.subtitleLabel.text = app.subtitle
cell.imageNames = Array(app.screenshotNames.prefix(3))
cell.appIconImageView.image = UIImage(named: app.iconName)
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.actionButton.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
cell.actionButton.activityIndicatorView.style = .white
cell.subtitleLabel.text = app.subtitle
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
cell.bannerView.titleLabel.text = app.name
cell.bannerView.subtitleLabel.text = app.developerName
cell.bannerView.betaBadgeView.isHidden = !app.isBeta
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
cell.bannerView.button.activityIndicatorView.style = .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.actionButton.isIndicatingActivity = false
cell.bannerView.button.isIndicatingActivity = false
let tintColor = app.tintColor ?? .altGreen
let tintColor = app.tintColor ?? .altPrimary
cell.tintColor = tintColor
if app.installedApp == nil
{
cell.actionButton.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
cell.bannerView.button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
let progress = AppManager.shared.installationProgress(for: app)
cell.actionButton.progress = progress
cell.actionButton.isInverted = false
cell.bannerView.button.progress = progress
if Date() < app.versionDate
{
cell.bannerView.button.countdownDate = app.versionDate
}
else
{
cell.bannerView.button.countdownDate = nil
}
}
else
{
cell.actionButton.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
cell.actionButton.progress = nil
cell.actionButton.isInverted = true
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
cell.bannerView.button.progress = nil
cell.bannerView.button.countdownDate = nil
}
}
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
let iconURL = storeApp.iconURL
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! BrowseCollectionViewCell
cell.bannerView.iconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
dataSource.placeholderView = self.placeholderView
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
AppManager.shared.fetchSource() { (result) in
do
{
{
let source = try result.get()
try source.managedObjectContext?.save()
DispatchQueue.main.async {
self.loadingState = .finished(.success(()))
}
}
catch
{
DispatchQueue.main.async {
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
if self.dataSource.itemCount > 0
{
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
self.loadingState = .finished(.failure(error))
}
}
}
}
func update()
{
switch self.loadingState
{
case .loading:
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = false
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
self.placeholderView.activityIndicatorView.startAnimating()
case .finished(.failure(let error)):
self.placeholderView.textLabel.isHidden = false
self.placeholderView.detailTextLabel.isHidden = false
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
self.placeholderView.detailTextLabel.text = error.localizedDescription
self.placeholderView.activityIndicatorView.stopAnimating()
case .finished(.success):
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = true
self.placeholderView.activityIndicatorView.stopAnimating()
}
}
}
private extension BrowseViewController
@@ -192,8 +288,8 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
let maxVisibleScreenshots = 2 as CGFloat
let aspectRatio: CGFloat = 16.0 / 9.0
let layout = collectionViewLayout as! UICollectionViewFlowLayout
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1))
let layout = self.prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
@@ -201,6 +297,8 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
widthConstraint.isActive = true
defer { widthConstraint.isActive = false }
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
self.prototypeCell.frame.size.width = widthConstraint.constant
self.prototypeCell.layoutIfNeeded()
let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width
@@ -208,6 +306,7 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
let screenshotHeight = screenshotWidth * aspectRatio
let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
heightConstraint.isActive = true
defer { heightConstraint.isActive = false }

View File

@@ -0,0 +1,49 @@
//
// AppBannerView.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
class AppBannerView: RSTNibView
{
private var originalTintColor: UIColor?
@IBOutlet var titleLabel: UILabel!
@IBOutlet var subtitleLabel: UILabel!
@IBOutlet var iconImageView: AppIconImageView!
@IBOutlet var button: PillButton!
@IBOutlet var buttonLabel: UILabel!
@IBOutlet var betaBadgeView: UIView!
@IBOutlet var backgroundEffectView: UIVisualEffectView!
@IBOutlet private var vibrancyView: UIVisualEffectView!
override func tintColorDidChange()
{
super.tintColorDidChange()
if self.tintAdjustmentMode != .dimmed
{
self.originalTintColor = self.tintColor
}
self.update()
}
}
private extension AppBannerView
{
func update()
{
self.clipsToBounds = true
self.layer.cornerRadius = 22
self.subtitleLabel.textColor = self.originalTintColor ?? self.tintColor
self.backgroundEffectView.backgroundColor = self.originalTintColor ?? self.tintColor
}
}

View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" 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="15404"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<connections>
<outlet property="backgroundEffectView" destination="rZk-be-tiI" id="fzU-VT-JeW"/>
<outlet property="betaBadgeView" destination="qQl-Ez-zC5" id="6O1-Cx-7qz"/>
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
<outlet property="buttonLabel" destination="Yd9-jw-faD" id="o7g-Gb-CIt"/>
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
<outlet property="vibrancyView" destination="fC4-1V-iMn" id="PXE-2B-A7w"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="FxI-Fh-ll5">
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rZk-be-tiI">
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="b8k-up-HtI">
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" name="BlurTint"/>
</view>
<blurEffect style="systemChromeMaterial"/>
</visualEffectView>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="14" y="14" width="60" height="60"/>
<constraints>
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
<rect key="frame" x="85" y="18" width="195" height="52"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
<rect key="frame" x="0.0" y="0.0" width="167" height="34"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
<rect key="frame" x="0.0" y="0.0" width="79" height="34"/>
<accessibility key="accessibilityConfiguration" identifier="NameLabel"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
<rect key="frame" x="85" y="0.0" width="82" height="34"/>
<accessibility key="accessibilityConfiguration" identifier="Beta Badge">
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
</imageView>
</subviews>
</stackView>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
<rect key="frame" x="0.0" y="36" width="195" 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="195" 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="195" height="16"/>
<accessibility key="accessibilityConfiguration" label="Developer"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="oN5-vu-Dnw" firstAttribute="top" secondItem="LQh-pN-ePC" secondAttribute="top" id="7RH-WP-LzL"/>
<constraint firstItem="oN5-vu-Dnw" firstAttribute="leading" secondItem="LQh-pN-ePC" secondAttribute="leading" id="By8-cR-kTu"/>
<constraint firstAttribute="trailing" secondItem="oN5-vu-Dnw" secondAttribute="trailing" id="Hiv-6y-XrH"/>
<constraint firstAttribute="bottom" secondItem="oN5-vu-Dnw" secondAttribute="bottom" id="yc2-Dr-Qnv"/>
</constraints>
</view>
<vibrancyEffect style="secondaryLabel">
<blurEffect style="systemChromeMaterial"/>
</vibrancyEffect>
</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="291" y="28.5" width="72" 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="72" height="0.0"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="72" height="31"/>
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="72" id="eGc-Dk-QbL"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="FREE"/>
</button>
</subviews>
</stackView>
</subviews>
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
</stackView>
</subviews>
<constraints>
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
<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" id="nJo-To-LmX"/>
<constraint firstItem="rZk-be-tiI" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="pGD-Tl-U4c"/>
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="q2p-0S-Nv5"/>
<constraint firstAttribute="bottom" secondItem="rZk-be-tiI" secondAttribute="bottom" id="yk0-pw-joP"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<point key="canvasLocation" x="139.85507246376812" y="152.67857142857142"/>
</view>
</objects>
<resources>
<image name="BetaBadge" width="41" height="17"/>
<namedColor name="BlurTint">
<color red="1" green="1" blue="1" alpha="0.29999999999999999" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -19,13 +19,16 @@ class AppIconImageView: UIImageView
self.backgroundColor = .white
self.layer.borderWidth = 0.5
self.layer.borderColor = self.tintColor.cgColor
// Allows us to match system look for app icons.
if self.layer.responds(to: Selector(("continuousCorners")))
if #available(iOS 13, *)
{
self.layer.setValue(true, forKey: "continuousCorners")
self.layer.cornerCurve = .continuous
}
else
{
if self.layer.responds(to: Selector(("continuousCorners")))
{
self.layer.setValue(true, forKey: "continuousCorners")
}
}
}
@@ -37,11 +40,4 @@ class AppIconImageView: UIImageView
let radius = self.bounds.height / 5
self.layer.cornerRadius = radius
}
override func tintColorDidChange()
{
super.tintColorDidChange()
self.layer.borderColor = self.tintColor.cgColor
}
}

View File

@@ -58,30 +58,33 @@ class CollapsingTextView: UITextView
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
self.moreButton.titleLabel?.font = buttonFont
let buttonY = (font.lineHeight + self.lineSpacing) * CGFloat(self.maximumNumberOfLines - 1)
let size = self.moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
let moreButtonFrame = CGRect(x: self.bounds.width - self.moreButton.bounds.width,
y: self.bounds.height - self.moreButton.bounds.height - self.lineSpacing,
y: buttonY,
width: size.width,
height: font.lineHeight)
self.moreButton.frame = moreButtonFrame
if self.isCollapsed
{
var exclusionFrame = moreButtonFrame
exclusionFrame.origin.y += self.moreButton.bounds.midY
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
let maximumCollapsedHeight = font.lineHeight * CGFloat(self.maximumNumberOfLines)
if self.bounds.height > maximumCollapsedHeight
if self.intrinsicContentSize.height > maximumCollapsedHeight
{
var exclusionFrame = moreButtonFrame
exclusionFrame.origin.y += self.moreButton.bounds.midY
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
self.moreButton.isHidden = false
}
else
{
self.textContainer.exclusionPaths = []
self.moreButton.isHidden = true
}
}

View File

@@ -0,0 +1,20 @@
//
// ForwardingNavigationController.swift
// AltStore
//
// Created by Riley Testut on 10/24/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
class ForwardingNavigationController: UINavigationController
{
override var childForStatusBarStyle: UIViewController? {
return self.topViewController
}
override var childForStatusBarHidden: UIViewController? {
return self.topViewController
}
}

View File

@@ -11,11 +11,68 @@ import KeychainAccess
import AltSign
@propertyWrapper
struct KeychainItem<Value>
{
let key: String
var wrappedValue: Value? {
get {
switch Value.self
{
case is Data.Type: return try? Keychain.shared.keychain.getData(self.key) as? Value
case is String.Type: return try? Keychain.shared.keychain.getString(self.key) as? Value
default: return nil
}
}
set {
switch Value.self
{
case is Data.Type: Keychain.shared.keychain[data: self.key] = newValue as? Data
case is String.Type: Keychain.shared.keychain[self.key] = newValue as? String
default: break
}
}
}
init(key: String)
{
self.key = key
}
}
class Keychain
{
static let shared = Keychain()
private let keychain = KeychainAccess.Keychain(service: "com.rileytestut.AltStore").accessibility(.afterFirstUnlock).synchronizable(true)
fileprivate let keychain = KeychainAccess.Keychain(service: "com.rileytestut.AltStore").accessibility(.afterFirstUnlock).synchronizable(true)
@KeychainItem(key: "appleIDEmailAddress")
var appleIDEmailAddress: String?
@KeychainItem(key: "appleIDPassword")
var appleIDPassword: String?
@KeychainItem(key: "signingCertificatePrivateKey")
var signingCertificatePrivateKey: Data?
@KeychainItem(key: "signingCertificateSerialNumber")
var signingCertificateSerialNumber: String?
@KeychainItem(key: "signingCertificate")
var signingCertificate: Data?
@KeychainItem(key: "signingCertificatePassword")
var signingCertificatePassword: String?
@KeychainItem(key: "patreonAccessToken")
var patreonAccessToken: String?
@KeychainItem(key: "patreonRefreshToken")
var patreonRefreshToken: String?
@KeychainItem(key: "patreonCreatorAccessToken")
var patreonCreatorAccessToken: String?
private init()
{
@@ -29,46 +86,3 @@ class Keychain
self.signingCertificateSerialNumber = nil
}
}
extension Keychain
{
var appleIDEmailAddress: String? {
get {
let emailAddress = try? self.keychain.get("appleIDEmailAddress")
return emailAddress
}
set {
self.keychain["appleIDEmailAddress"] = newValue
}
}
var appleIDPassword: String? {
get {
let password = try? self.keychain.get("appleIDPassword")
return password
}
set {
self.keychain["appleIDPassword"] = newValue
}
}
var signingCertificatePrivateKey: Data? {
get {
let privateKey = try? self.keychain.getData("signingCertificatePrivateKey")
return privateKey
}
set {
self.keychain[data: "signingCertificatePrivateKey"] = newValue
}
}
var signingCertificateSerialNumber: String? {
get {
let serialNumber = try? self.keychain.get("signingCertificateSerialNumber")
return serialNumber
}
set {
self.keychain["signingCertificateSerialNumber"] = newValue
}
}
}

View File

@@ -12,6 +12,10 @@ import Roxas
class NavigationBar: UINavigationBar
{
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
private let backgroundColorView = UIView()
override init(frame: CGRect)
{
super.init(frame: frame)
@@ -28,19 +32,72 @@ class NavigationBar: UINavigationBar
private func initialize()
{
self.barTintColor = .white
self.shadowImage = UIImage()
if #available(iOS 13, *)
{
let standardAppearance = UINavigationBarAppearance()
standardAppearance.configureWithDefaultBackground()
standardAppearance.shadowColor = nil
let edgeAppearance = UINavigationBarAppearance()
edgeAppearance.configureWithOpaqueBackground()
edgeAppearance.backgroundColor = self.barTintColor
edgeAppearance.shadowColor = nil
if let tintColor = self.barTintColor
{
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
standardAppearance.backgroundColor = tintColor
standardAppearance.titleTextAttributes = textAttributes
standardAppearance.largeTitleTextAttributes = textAttributes
edgeAppearance.titleTextAttributes = textAttributes
edgeAppearance.largeTitleTextAttributes = textAttributes
}
else
{
standardAppearance.backgroundColor = nil
}
self.scrollEdgeAppearance = edgeAppearance
self.standardAppearance = standardAppearance
}
else
{
self.shadowImage = UIImage()
if let tintColor = self.barTintColor
{
self.backgroundColorView.backgroundColor = tintColor
// Top = -50 to cover status bar area above navigation bar on any device.
// Bottom = -1 to prevent a flickering gray line from appearing.
self.addSubview(self.backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
}
else
{
self.barTintColor = .white
}
}
}
override func layoutSubviews()
{
super.layoutSubviews()
// We can't easily shift just the back button up, so we shift the entire content view slightly.
for contentView in self.subviews
if self.backgroundColorView.superview != nil
{
guard NSStringFromClass(type(of: contentView)).contains("ContentView") else { continue }
contentView.center.y -= 2
self.insertSubview(self.backgroundColorView, at: 1)
}
if self.automaticallyAdjustsItemPositions
{
// We can't easily shift just the back button up, so we shift the entire content view slightly.
for contentView in self.subviews
{
guard NSStringFromClass(type(of: contentView)).contains("ContentView") else { continue }
contentView.center.y -= 2
}
}
}
}

View File

@@ -11,13 +11,15 @@ import UIKit
class PillButton: UIButton
{
var progress: Progress? {
didSet {
didSet {
self.progressView.progress = Float(self.progress?.fractionCompleted ?? 0)
self.progressView.observedProgress = self.progress
let isUserInteractionEnabled = self.isUserInteractionEnabled
self.isIndicatingActivity = (self.progress != nil)
self.isUserInteractionEnabled = isUserInteractionEnabled
self.update()
}
}
@@ -30,14 +32,35 @@ class PillButton: UIButton
}
}
var isInverted: Bool = false {
var countdownDate: Date? {
didSet {
self.update()
self.isEnabled = (self.countdownDate == nil)
self.displayLink.isPaused = (self.countdownDate == nil)
if self.countdownDate == nil
{
self.setTitle(nil, for: .disabled)
}
}
}
private let progressView = UIProgressView(progressViewStyle: .default)
private lazy var displayLink: CADisplayLink = {
let displayLink = CADisplayLink(target: self, selector: #selector(PillButton.updateCountdown))
displayLink.preferredFramesPerSecond = 15
displayLink.isPaused = true
displayLink.add(to: .main, forMode: .common)
return displayLink
}()
private let dateComponentsFormatter: DateComponentsFormatter = {
let dateComponentsFormatter = DateComponentsFormatter()
dateComponentsFormatter.zeroFormattingBehavior = [.pad]
dateComponentsFormatter.collapsesLargestUnit = false
return dateComponentsFormatter
}()
override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.width += 26
@@ -45,6 +68,11 @@ class PillButton: UIButton
return size
}
deinit
{
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
}
override func awakeFromNib()
{
super.awakeFromNib()
@@ -88,17 +116,68 @@ private extension PillButton
{
func update()
{
if self.isInverted
if self.progress == nil
{
self.setTitleColor(.white, for: .normal)
self.backgroundColor = self.tintColor
self.progressView.progressTintColor = self.tintColor.withAlphaComponent(0.15)
}
else
{
self.setTitleColor(self.tintColor, for: .normal)
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
self.progressView.progressTintColor = self.tintColor
}
self.progressView.progressTintColor = self.tintColor
}
@objc func updateCountdown()
{
guard let endDate = self.countdownDate else { return }
let startDate = Date()
let interval = endDate.timeIntervalSince(startDate)
guard interval > 0 else {
self.isEnabled = true
return
}
let text: String?
if interval < (1 * 60 * 60)
{
self.dateComponentsFormatter.unitsStyle = .positional
self.dateComponentsFormatter.allowedUnits = [.minute, .second]
text = self.dateComponentsFormatter.string(from: startDate, to: endDate)
}
else if interval < (2 * 24 * 60 * 60)
{
self.dateComponentsFormatter.unitsStyle = .positional
self.dateComponentsFormatter.allowedUnits = [.hour, .minute, .second]
text = self.dateComponentsFormatter.string(from: startDate, to: endDate)
}
else
{
self.dateComponentsFormatter.unitsStyle = .full
self.dateComponentsFormatter.allowedUnits = [.day]
let numberOfDays = endDate.numberOfCalendarDays(since: startDate)
text = String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays))
}
if let text = text
{
UIView.performWithoutAnimation {
self.isEnabled = false
self.setTitle(text, for: .disabled)
self.layoutIfNeeded()
}
}
else
{
self.isEnabled = true
}
}
}

View File

@@ -10,8 +10,9 @@ import UIKit
extension UIColor
{
static let altPurple = UIColor(named: "Purple")!
static let altGreen = UIColor(named: "Green")!
static let altPrimary = UIColor(named: "Primary")!
static let altPink = UIColor(named: "Pink")!
static let refreshRed = UIColor(named: "RefreshRed")!
static let refreshOrange = UIColor(named: "RefreshOrange")!

View File

@@ -0,0 +1,16 @@
//
// UIScreen+CompactHeight.swift
// AltStore
//
// Created by Riley Testut on 9/6/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
extension UIScreen
{
var isExtraCompactHeight: Bool {
return self.fixedCoordinateSpace.bounds.height < 600
}
}

View File

@@ -8,7 +8,20 @@
import Foundation
import Roxas
extension UserDefaults
{
@NSManaged var firstLaunch: Date?
@NSManaged var preferredServerID: String?
@NSManaged var isBackgroundRefreshEnabled: Bool
@NSManaged var isDebugModeEnabled: Bool
@NSManaged var presentedLaunchReminderNotification: Bool
func registerDefaults()
{
self.register(defaults: [#keyPath(UserDefaults.isBackgroundRefreshEnabled): true])
}
}

View File

@@ -3,9 +3,28 @@
<plist version="1.0">
<dict>
<key>ALTDeviceID</key>
<string>1c3416b7b0ab68773e6e7eb7f0d110f7c9353acc</string>
<string>00008030-001948590202802E</string>
<key>ALTServerID</key>
<string>1AAAB6FD-E8CE-4B70-8F26-4073215C03B0</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeIconFiles</key>
<array/>
<key>CFBundleTypeName</key>
<string>iOS App</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.apple.itunes.ipa</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -17,17 +36,17 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>AltStore</string>
<string>AltStore General</string>
<key>CFBundleURLSchemes</key>
<array>
<string>altstore-com.rileytestut.altstore</string>
<string>altstore</string>
</array>
</dict>
</array>
@@ -36,8 +55,13 @@
<key>LSApplicationQueriesSchemes</key>
<array>
<string>altstore-com.rileytestut.AltStore</string>
<string>altstore-com.rileytestut.AltStore.Beta</string>
<string>altstore-com.rileytestut.Delta</string>
<string>altstore-com.rileytestut.Delta.Beta</string>
<string>altstore-com.rileytestut.Delta.Lite</string>
<string>altstore-com.rileytestut.Delta.Lite.Beta</string>
<string>altstore-com.rileytestut.Clip</string>
<string>altstore-com.rileytestut.Clip.Beta</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
@@ -76,5 +100,25 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>iOS App</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>com.apple.itunes.ipa</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>ipa</string>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -11,6 +11,8 @@ import Roxas
class LaunchViewController: RSTLaunchViewController
{
private var didFinishLaunching = false
override var launchConditions: [RSTLaunchCondition] {
let isDatabaseStarted = RSTLaunchCondition(condition: { DatabaseManager.shared.isStarted }) { (completionHandler) in
DatabaseManager.shared.start(completionHandler: completionHandler)
@@ -18,6 +20,14 @@ class LaunchViewController: RSTLaunchViewController
return [isDatabaseStarted]
}
override var childForStatusBarStyle: UIViewController? {
return self.children.first
}
override var childForStatusBarHidden: UIViewController? {
return self.children.first
}
}
extension LaunchViewController
@@ -44,8 +54,24 @@ extension LaunchViewController
{
super.finishLaunching()
AppManager.shared.update()
guard !self.didFinishLaunching else { return }
self.performSegue(withIdentifier: "finishLaunching", sender: nil)
AppManager.shared.update()
PatreonAPI.shared.refreshPatreonAccount()
// Add view controller as child (rather than presenting modally)
// so tint adjustment + card presentations works correctly.
let viewController = self.storyboard!.instantiateViewController(withIdentifier: "tabBarController") as! TabBarController
viewController.view.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
viewController.view.alpha = 0.0
self.addChild(viewController)
self.view.addSubview(viewController.view, pinningEdgesWith: .zero)
viewController.didMove(toParent: self)
UIView.animate(withDuration: 0.2) {
viewController.view.alpha = 1.0
}
self.didFinishLaunching = true
}
}

View File

@@ -8,6 +8,7 @@
import Foundation
import UIKit
import UserNotifications
import AltSign
import AltKit
@@ -17,6 +18,8 @@ import Roxas
extension AppManager
{
static let didFetchSourceNotification = Notification.Name("com.altstore.AppManager.didFetchSource")
static let expirationWarningNotificationID = "altstore-expiration-warning"
}
class AppManager
@@ -54,13 +57,16 @@ extension AppManager
let installedApps = try context.fetch(fetchRequest)
for app in installedApps where app.storeApp != nil
{
if UIApplication.shared.canOpenURL(app.openAppURL)
if app.bundleIdentifier == StoreApp.altstoreAppID
{
// App is still installed, good!
self.scheduleExpirationWarningLocalNotification(for: app)
}
else
{
context.delete(app)
if !UIApplication.shared.canOpenURL(app.openAppURL)
{
context.delete(app)
}
}
}
@@ -74,12 +80,26 @@ extension AppManager
#endif
}
func authenticate(presentingViewController: UIViewController?, completionHandler: @escaping (Result<ALTSigner, Error>) -> Void)
func authenticate(presentingViewController: UIViewController?, completionHandler: @escaping (Result<(ALTSigner, ALTAppleAPISession), Error>) -> Void)
{
let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController)
let group = OperationGroup()
let findServerOperation = FindServerOperation(group: group)
findServerOperation.resultHandler = { (result) in
switch result
{
case .failure(let error): group.error = error
case .success(let server): group.server = server
}
}
let authenticationOperation = AuthenticationOperation(group: group, presentingViewController: presentingViewController)
authenticationOperation.addDependency(findServerOperation)
authenticationOperation.resultHandler = { (result) in
completionHandler(result)
}
self.operationQueue.addOperation(findServerOperation)
self.operationQueue.addOperation(authenticationOperation)
}
}
@@ -143,7 +163,7 @@ extension AppManager
func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, group: OperationGroup? = nil) -> OperationGroup
{
let apps = installedApps.filter { self.refreshProgress(for: $0) == nil }
let apps = installedApps.filter { self.refreshProgress(for: $0) == nil || self.refreshProgress(for: $0)?.isCancelled == true }
let group = self.install(apps, forceDownload: false, presentingViewController: presentingViewController, group: group)
@@ -175,31 +195,43 @@ private extension AppManager
{
// Authenticate -> Download (if necessary) -> Resign -> Send -> Install.
let group = group ?? OperationGroup()
guard let server = ServerManager.shared.discoveredServers.first else {
DispatchQueue.main.async {
group.completionHandler?(.failure(ConnectionError.serverNotFound))
}
return group
}
group.server = server
var operations = [Operation]()
/* Authenticate */
let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController)
authenticationOperation.resultHandler = { (result) in
/* Find Server */
let findServerOperation = FindServerOperation(group: group)
findServerOperation.resultHandler = { (result) in
switch result
{
case .failure(let error): group.error = error
case .success(let signer): group.signer = signer
case .success(let server): group.server = server
}
}
operations.append(authenticationOperation)
operations.append(findServerOperation)
let authenticationOperation: AuthenticationOperation?
if group.signer == nil || group.session == nil
{
/* Authenticate */
let operation = AuthenticationOperation(group: group, presentingViewController: presentingViewController)
operation.resultHandler = { (result) in
switch result
{
case .failure(let error): group.error = error
case .success(let signer, let session):
group.signer = signer
group.session = session
}
}
operations.append(operation)
operation.addDependency(findServerOperation)
authenticationOperation = operation
}
else
{
authenticationOperation = nil
}
for app in apps
{
@@ -213,7 +245,7 @@ private extension AppManager
guard let resignedApp = self.process(result, context: context) else { return }
context.resignedApp = resignedApp
}
resignAppOperation.addDependency(authenticationOperation)
resignAppOperation.addDependency(authenticationOperation ?? findServerOperation)
progress.addChild(resignAppOperation.progress, withPendingUnitCount: 20)
operations.append(resignAppOperation)
@@ -246,12 +278,13 @@ private extension AppManager
{
// App is not yet installed (or we're forcing it to download a new version), so download it before resigning it.
let downloadOperation = DownloadAppOperation(app: app)
let downloadOperation = DownloadAppOperation(app: app, context: context)
downloadOperation.resultHandler = { (result) in
guard let app = self.process(result, context: context) else { return }
context.app = app
}
progress.addChild(downloadOperation.progress, withPendingUnitCount: 40)
downloadOperation.addDependency(findServerOperation)
resignAppOperation.addDependency(downloadOperation)
operations.append(downloadOperation)
}
@@ -267,6 +300,16 @@ private extension AppManager
operations.append(sendAppOperation)
let beginInstallationHandler = group.beginInstallationHandler
group.beginInstallationHandler = { (installedApp) in
if installedApp.bundleIdentifier == StoreApp.altstoreAppID
{
self.scheduleExpirationWarningLocalNotification(for: installedApp)
}
beginInstallationHandler?(installedApp)
}
/* Install */
let installOperation = InstallAppOperation(context: context)
installOperation.resultHandler = { (result) in
@@ -326,11 +369,32 @@ private extension AppManager
guard !context.isFinished else { return }
context.isFinished = true
self.refreshProgress[context.bundleIdentifier] = nil
if let progress = self.refreshProgress[context.bundleIdentifier], progress == context.group.progress(forAppWithBundleIdentifier: context.bundleIdentifier)
{
// Only remove progress if it hasn't been replaced by another one.
self.refreshProgress[context.bundleIdentifier] = nil
}
if let error = context.error
{
context.group.results[context.bundleIdentifier] = .failure(error)
switch error
{
case let error as ALTServerError where error.code == .deviceNotFound || error.code == .lostConnection:
if let server = context.group.server, server.isPreferred
{
// Preferred server, so report errors normally.
context.group.results[context.bundleIdentifier] = .failure(error)
}
else
{
// Not preferred server, so ignore these specific errors and throw serverNotFound instead.
context.group.results[context.bundleIdentifier] = .failure(ConnectionError.serverNotFound)
}
case let error:
context.group.results[context.bundleIdentifier] = .failure(error)
}
}
else if let installedApp = context.installedApp
{
@@ -341,17 +405,41 @@ private extension AppManager
do { try installedApp.managedObjectContext?.save() }
catch { print("Error saving installed app.", error) }
}
}
do { try FileManager.default.removeItem(at: context.temporaryDirectory) }
catch { print("Failed to remove temporary directory.", error) }
}
print("Finished operation!", context.bundleIdentifier)
if context.group.results.count == context.group.progress.totalUnitCount
{
context.group.completionHandler?(.success(context.group.results))
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
backgroundContext.performAndWait {
guard let altstore = InstalledApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID), in: backgroundContext) else { return }
self.scheduleExpirationWarningLocalNotification(for: altstore)
}
}
}
}
func scheduleExpirationWarningLocalNotification(for app: InstalledApp)
{
let notificationDate = app.expirationDate.addingTimeInterval(-1 * 60 * 60 * 24) // 24 hours before expiration.
let timeIntervalUntilNotification = notificationDate.timeIntervalSinceNow
guard timeIntervalUntilNotification > 0 else {
// Crashes if we pass negative value to UNTimeIntervalNotificationTrigger initializer.
return
}
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeIntervalUntilNotification, repeats: false)
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("AltStore Expiring Soon", comment: "")
content.body = NSLocalizedString("AltStore will expire in 24 hours. Open the app and refresh it to prevent it from expiring.", comment: "")
content.sound = .default
let request = UNNotificationRequest(identifier: AppManager.expirationWarningNotificationID, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>AltStore 2.xcdatamodel</string>
</dict>
</plist>

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14492.1" systemVersion="18G95" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="appleID" attributeType="String" syncable="YES"/>
<attribute name="firstName" attributeType="String" syncable="YES"/>
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="isActiveAccount" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="lastName" attributeType="String" syncable="YES"/>
<relationship name="teams" toMany="YES" deletionRule="Cascade" destinationEntity="Team" inverseName="account" inverseEntity="Team" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AppPermission" representedClassName="AppPermission" syncable="YES">
<attribute name="type" attributeType="String" syncable="YES"/>
<attribute name="usageDescription" attributeType="String" syncable="YES"/>
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="permissions" inverseEntity="StoreApp" syncable="YES"/>
</entity>
<entity name="InstalledApp" representedClassName="InstalledApp" syncable="YES">
<attribute name="bundleIdentifier" attributeType="String" syncable="YES"/>
<attribute name="expirationDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="refreshedDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="resignedBundleIdentifier" attributeType="String" syncable="YES"/>
<attribute name="version" attributeType="String" syncable="YES"/>
<relationship name="storeApp" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="installedApp" inverseEntity="StoreApp" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="bundleIdentifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="NewsItem" representedClassName="NewsItem" syncable="YES">
<attribute name="appID" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="caption" attributeType="String" syncable="YES"/>
<attribute name="date" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="externalURL" optional="YES" attributeType="URI" syncable="YES"/>
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="imageURL" optional="YES" attributeType="URI" syncable="YES"/>
<attribute name="isSilent" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="sortIndex" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="tintColor" optional="YES" attributeType="Transformable" syncable="YES"/>
<attribute name="title" attributeType="String" syncable="YES"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="newsItems" inverseEntity="Source" syncable="YES"/>
<relationship name="storeApp" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="newsItems" inverseEntity="StoreApp" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PatreonAccount" representedClassName="PatreonAccount" syncable="YES">
<attribute name="firstName" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="isPatron" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="RefreshAttempt" representedClassName="RefreshAttempt" syncable="YES">
<attribute name="date" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="errorDescription" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="isSuccess" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="sourceURL" attributeType="URI" syncable="YES"/>
<relationship name="apps" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="StoreApp" inverseName="source" inverseEntity="StoreApp" syncable="YES"/>
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="NewsItem" inverseName="source" inverseEntity="NewsItem" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="StoreApp" representedClassName="StoreApp" syncable="YES">
<attribute name="bundleIdentifier" attributeType="String" syncable="YES"/>
<attribute name="developerName" attributeType="String" syncable="YES"/>
<attribute name="downloadURL" attributeType="URI" syncable="YES"/>
<attribute name="iconURL" attributeType="URI" syncable="YES"/>
<attribute name="isBeta" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="localizedDescription" attributeType="String" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="screenshotURLs" attributeType="Transformable" syncable="YES"/>
<attribute name="size" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="sortIndex" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="tintColor" optional="YES" attributeType="Transformable" syncable="YES"/>
<attribute name="version" attributeType="String" syncable="YES"/>
<attribute name="versionDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="versionDescription" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="installedApp" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InstalledApp" inverseName="storeApp" inverseEntity="InstalledApp" syncable="YES"/>
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" destinationEntity="NewsItem" inverseName="storeApp" inverseEntity="NewsItem" syncable="YES"/>
<relationship name="permissions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission" syncable="YES"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="bundleIdentifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Team" representedClassName="Team" syncable="YES">
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="isActiveTeam" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="type" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="teams" inverseEntity="Account" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="-36" positionY="90" width="128" height="135"/>
<element name="AppPermission" positionX="-45" positionY="90" width="128" height="90"/>
<element name="InstalledApp" positionX="-63" positionY="0" width="128" height="150"/>
<element name="NewsItem" positionX="-45" positionY="126" width="128" height="225"/>
<element name="PatreonAccount" positionX="-45" positionY="117" width="128" height="105"/>
<element name="RefreshAttempt" positionX="-45" positionY="117" width="128" height="105"/>
<element name="Source" positionX="-45" positionY="99" width="128" height="120"/>
<element name="StoreApp" positionX="-63" positionY="-18" width="128" height="330"/>
<element name="Team" positionX="-45" positionY="81" width="128" height="120"/>
</elements>
</model>

View File

@@ -115,6 +115,12 @@ extension DatabaseManager
let activeTeam = Team.first(satisfying: predicate, in: context)
return activeTeam
}
func patreonAccount(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> PatreonAccount?
{
let patronAccount = PatreonAccount.first(in: context)
return patronAccount
}
}
private extension DatabaseManager
@@ -125,6 +131,20 @@ private extension DatabaseManager
context.performAndWait {
guard let localApp = ALTApplication(fileURL: Bundle.main.bundleURL) else { return }
let altStoreSource: Source
if let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context)
{
altStoreSource = source
}
else
{
altStoreSource = Source.makeAltStoreSource(in: context)
}
// Make sure to always update source URL to be current.
altStoreSource.sourceURL = Source.altStoreSourceURL
let storeApp: StoreApp
if let app = StoreApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID), in: context)
@@ -133,13 +153,11 @@ private extension DatabaseManager
}
else
{
let source = Source.makeAltStoreSource(in: context)
storeApp = StoreApp.makeAltStoreApp(in: context)
storeApp.version = localApp.version
storeApp.source = source
storeApp.source = altStoreSource
}
let installedApp: InstalledApp
if let app = storeApp.installedApp
@@ -152,31 +170,32 @@ private extension DatabaseManager
installedApp.storeApp = storeApp
}
installedApp.version = localApp.version
let fileURL = installedApp.fileURL
if !FileManager.default.fileExists(atPath: fileURL.path)
if !FileManager.default.fileExists(atPath: fileURL.path) || installedApp.version != localApp.version
{
do
{
try FileManager.default.copyItem(at: Bundle.main.bundleURL, to: fileURL)
let infoPlistURL = fileURL.appendingPathComponent("Info.plist")
// TODO: Copy to temporary location, modify it, _then_ copy to final destination.
guard var infoDictionary = Bundle.main.infoDictionary else { throw ALTError(.missingInfoPlist) }
infoDictionary[kCFBundleIdentifierKey as String] = StoreApp.altstoreAppID
try (infoDictionary as NSDictionary).write(to: infoPlistURL)
}
catch
{
print("Failed to copy AltStore app bundle to its proper location.", error)
try? FileManager.default.removeItem(at: fileURL)
FileManager.default.prepareTemporaryURL() { (temporaryFileURL) in
do
{
try FileManager.default.copyItem(at: Bundle.main.bundleURL, to: temporaryFileURL)
let infoPlistURL = temporaryFileURL.appendingPathComponent("Info.plist")
guard var infoDictionary = Bundle.main.infoDictionary else { throw ALTError(.missingInfoPlist) }
infoDictionary[kCFBundleIdentifierKey as String] = StoreApp.altstoreAppID
try (infoDictionary as NSDictionary).write(to: infoPlistURL)
try FileManager.default.copyItem(at: temporaryFileURL, to: fileURL, shouldReplace: true)
}
catch
{
print("Failed to copy AltStore app bundle to its proper location.", error)
}
}
}
// Must go after comparing versions to see if we need to update our cached AltStore app bundle.
installedApp.version = localApp.version
if let provisioningProfile = localApp.provisioningProfile
{
installedApp.refreshedDate = provisioningProfile.creationDate

View File

@@ -82,7 +82,17 @@ extension InstalledApp
class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp]
{
let predicate = NSPredicate(format: "%K != %@", #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
var predicate = NSPredicate(format: "%K != %@", #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))])
}
var installedApps = InstalledApp.all(satisfying: predicate,
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
@@ -99,12 +109,23 @@ extension InstalledApp
class func fetchAppsForBackgroundRefresh(in context: NSManagedObjectContext) -> [InstalledApp]
{
let date = Date().addingTimeInterval(-120)
// Date 6 hours before now.
let date = Date().addingTimeInterval(-1 * 6 * 60 * 60)
let predicate = NSPredicate(format: "(%K < %@) AND (%K != %@)",
var predicate = NSPredicate(format: "(%K < %@) AND (%K != %@)",
#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))])
}
var installedApps = InstalledApp.all(satisfying: predicate,
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
in: context)

View File

@@ -34,6 +34,7 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break }
let bundleIdentifiers = Set(conflictedObject.apps.map { $0.bundleIdentifier })
let newsItemIdentifiers = Set(conflictedObject.newsItems.map { $0.identifier })
for app in databaseObject.apps
{
@@ -44,6 +45,15 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
}
}
for newsItem in databaseObject.newsItems
{
if !newsItemIdentifiers.contains(newsItem.identifier)
{
// No longer listed in Source, so remove it from database.
newsItem.managedObjectContext?.delete(newsItem)
}
}
default: break
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
//
// StoreAppPolicy.swift
// AltStore
//
// Created by Riley Testut on 9/14/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import CoreData
@objc(StoreAppToStoreAppMigrationPolicy)
class StoreAppToStoreAppMigrationPolicy: NSEntityMigrationPolicy
{
@objc(migrateIconURL)
func migrateIconURL() -> URL
{
return URL(string: "https://via.placeholder.com/150")!
}
@objc(migrateScreenshotURLs)
func migrateScreenshotURLs() -> NSCopying
{
return [] as NSArray
}
}

View File

@@ -0,0 +1,90 @@
//
// NewsItem.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import CoreData
@objc(NewsItem)
class NewsItem: NSManagedObject, Decodable, Fetchable
{
/* Properties */
@NSManaged var identifier: String
@NSManaged var date: Date
@NSManaged var title: String
@NSManaged var caption: String
@NSManaged var tintColor: UIColor
@NSManaged var sortIndex: Int32
@NSManaged var isSilent: Bool
@NSManaged var imageURL: URL?
@NSManaged var externalURL: URL?
@NSManaged var appID: String?
/* Relationships */
@NSManaged var storeApp: StoreApp?
@NSManaged var source: Source?
private enum CodingKeys: String, CodingKey
{
case identifier
case date
case title
case caption
case tintColor
case imageURL
case externalURL = "url"
case appID
case notify
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
required init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
super.init(entity: NewsItem.entity(), insertInto: context)
let container = try decoder.container(keyedBy: CodingKeys.self)
self.identifier = try container.decode(String.self, forKey: .identifier)
self.date = try container.decode(Date.self, forKey: .date)
self.title = try container.decode(String.self, forKey: .title)
self.caption = try container.decode(String.self, forKey: .caption)
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
{
guard let tintColor = UIColor(hexString: tintColorHex) else {
throw DecodingError.dataCorruptedError(forKey: .tintColor, in: container, debugDescription: "Hex code is invalid.")
}
self.tintColor = tintColor
}
self.imageURL = try container.decodeIfPresent(URL.self, forKey: .imageURL)
self.externalURL = try container.decodeIfPresent(URL.self, forKey: .externalURL)
self.appID = try container.decodeIfPresent(String.self, forKey: .appID)
let notify = try container.decodeIfPresent(Bool.self, forKey: .notify) ?? false
self.isSilent = !notify
}
}
extension NewsItem
{
@nonobjc class func fetchRequest() -> NSFetchRequest<NewsItem>
{
return NSFetchRequest<NewsItem>(entityName: "NewsItem")
}
}

View File

@@ -0,0 +1,74 @@
//
// 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)
class PatreonAccount: NSManagedObject, Fetchable
{
@NSManaged var identifier: String
@NSManaged var name: String
@NSManaged var firstName: String?
@NSManaged 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
}
}
}
extension PatreonAccount
{
@nonobjc class func fetchRequest() -> NSFetchRequest<PatreonAccount>
{
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
}
}

View File

@@ -11,6 +11,12 @@ import CoreData
extension Source
{
static let altStoreIdentifier = "com.rileytestut.AltStore"
#if STAGING
static let altStoreSourceURL = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/apps-staging.json")!
#else
static let altStoreSourceURL = URL(string: "https://cdn.altstore.io/file/altstore/apps.json")!
#endif
}
@objc(Source)
@@ -21,8 +27,12 @@ class Source: NSManagedObject, Fetchable, Decodable
@NSManaged var identifier: String
@NSManaged var sourceURL: URL
/* Non-Core Data Properties */
var userInfo: [ALTSourceUserInfoKey: String]?
/* Relationships */
@objc(apps) @NSManaged private(set) var _apps: NSOrderedSet
@objc(newsItems) @NSManaged private(set) var _newsItems: NSOrderedSet
@nonobjc var apps: [StoreApp] {
get {
@@ -33,12 +43,23 @@ class Source: NSManagedObject, Fetchable, Decodable
}
}
@nonobjc var newsItems: [NewsItem] {
get {
return self._newsItems.array as! [NewsItem]
}
set {
self._newsItems = NSOrderedSet(array: newValue)
}
}
private enum CodingKeys: String, CodingKey
{
case name
case identifier
case sourceURL
case userInfo
case apps
case news
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
@@ -57,16 +78,40 @@ class Source: NSManagedObject, Fetchable, Decodable
self.identifier = try container.decode(String.self, forKey: .identifier)
self.sourceURL = try container.decode(URL.self, forKey: .sourceURL)
let userInfo = try container.decodeIfPresent([String: String].self, forKey: .userInfo)
self.userInfo = userInfo?.reduce(into: [:]) { $0[ALTSourceUserInfoKey($1.key)] = $1.value }
let apps = try container.decodeIfPresent([StoreApp].self, forKey: .apps) ?? []
for (index, app) in apps.enumerated()
{
app.sortIndex = Int32(index)
}
let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? []
for (index, item) in newsItems.enumerated()
{
item.sortIndex = Int32(index)
}
context.insert(self)
let appsByID = Dictionary(apps.map { ($0.bundleIdentifier, $0) }, uniquingKeysWith: { (a, b) in return a })
for newsItem in newsItems
{
newsItem.source = self
guard let appID = newsItem.appID else { continue }
if let storeApp = appsByID[appID]
{
newsItem.storeApp = storeApp
}
}
// Must assign after we're inserted into context.
self._apps = NSMutableOrderedSet(array: apps)
self._newsItems = NSMutableOrderedSet(array: newsItems)
print("Downloaded Order:", self.apps.map { $0.bundleIdentifier })
}
@@ -79,7 +124,7 @@ extension Source
let source = Source(context: context)
source.name = "AltStore"
source.identifier = Source.altStoreIdentifier
source.sourceURL = URL(string: "https://www.dropbox.com/s/6qi1vt6hsi88lv6/Apps-Dev.json?dl=1")!
source.sourceURL = Source.altStoreSourceURL
return source
}

View File

@@ -14,7 +14,13 @@ import AltSign
extension StoreApp
{
#if BETA
static let altstoreAppID = "com.rileytestut.AltStore.Beta"
static let alternativeAltStoreAppID = "com.rileytestut.AltStore"
#else
static let altstoreAppID = "com.rileytestut.AltStore"
static let alternativeAltStoreAppID = "com.rileytestut.AltStore.Beta"
#endif
}
@objc(StoreApp)
@@ -29,8 +35,8 @@ class StoreApp: NSManagedObject, Decodable, Fetchable
@NSManaged private(set) var localizedDescription: String
@NSManaged private(set) var size: Int32
@NSManaged private(set) var iconName: String
@NSManaged private(set) var screenshotNames: [String]
@NSManaged private(set) var iconURL: URL
@NSManaged private(set) var screenshotURLs: [URL]
@NSManaged var version: String
@NSManaged private(set) var versionDate: Date
@@ -38,6 +44,7 @@ class StoreApp: NSManagedObject, Decodable, Fetchable
@NSManaged private(set) var downloadURL: URL
@NSManaged private(set) var tintColor: UIColor?
@NSManaged private(set) var isBeta: Bool
@NSManaged var sortIndex: Int32
@@ -64,13 +71,14 @@ class StoreApp: NSManagedObject, Decodable, Fetchable
case version
case versionDescription
case versionDate
case iconName
case screenshotNames
case iconURL
case screenshotURLs
case downloadURL
case tintColor
case subtitle
case permissions
case size
case isBeta = "beta"
}
required init(from decoder: Decoder) throws
@@ -91,8 +99,8 @@ class StoreApp: NSManagedObject, Decodable, Fetchable
self.versionDate = try container.decode(Date.self, forKey: .versionDate)
self.versionDescription = try container.decodeIfPresent(String.self, forKey: .versionDescription)
self.iconName = try container.decode(String.self, forKey: .iconName)
self.screenshotNames = try container.decodeIfPresent([String].self, forKey: .screenshotNames) ?? []
self.iconURL = try container.decode(URL.self, forKey: .iconURL)
self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
self.downloadURL = try container.decode(URL.self, forKey: .downloadURL)
@@ -106,6 +114,7 @@ class StoreApp: NSManagedObject, Decodable, Fetchable
}
self.size = try container.decode(Int32.self, forKey: .size)
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? []
@@ -130,12 +139,16 @@ extension StoreApp
app.bundleIdentifier = StoreApp.altstoreAppID
app.developerName = "Riley Testut"
app.localizedDescription = "AltStore is an alternative App Store."
app.iconName = ""
app.screenshotNames = []
app.iconURL = URL(string: "https://user-images.githubusercontent.com/705880/63392210-540c5980-c37b-11e9-968c-8742fc68ab2e.png")!
app.screenshotURLs = []
app.version = "1.0"
app.versionDate = Date()
app.downloadURL = URL(string: "http://rileytestut.com")!
#if BETA
app.isBeta = true
#endif
return app
}
}

View File

@@ -17,7 +17,7 @@ extension ALTTeamType
switch self
{
case .free: return NSLocalizedString("Free Developer Account", comment: "")
case .individual: return NSLocalizedString("Individual", comment: "")
case .individual: return NSLocalizedString("Developer", comment: "")
case .organization: return NSLocalizedString("Organization", comment: "")
case .unknown: fallthrough
@unknown default: return NSLocalizedString("Unknown", comment: "")

View File

@@ -10,10 +10,18 @@ import UIKit
class InstalledAppCollectionViewCell: UICollectionViewCell
{
@IBOutlet var appIconImageView: UIImageView!
@IBOutlet var nameLabel: UILabel!
@IBOutlet var developerLabel: UILabel!
@IBOutlet var refreshButton: PillButton!
@IBOutlet var bannerView: AppBannerView!
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.contentView.preservesSuperviewLayoutMargins = true
self.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
self.bannerView.buttonLabel.isHidden = false
}
}
class InstalledAppsCollectionHeaderView: UICollectionReusableView
@@ -22,6 +30,18 @@ class InstalledAppsCollectionHeaderView: UICollectionReusableView
@IBOutlet var button: UIButton!
}
class NoUpdatesCollectionViewCell: UICollectionViewCell
{
@IBOutlet var blurView: UIVisualEffectView!
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.preservesSuperviewLayoutMargins = true
}
}
class UpdatesCollectionHeaderView: UICollectionReusableView
{
let button = PillButton(type: .system)

View File

@@ -13,6 +13,8 @@ import Roxas
import AltSign
import Nuke
private let maximumCollapsedUpdatesCount = 2
extension MyAppsViewController
@@ -58,12 +60,18 @@ class MyAppsViewController: UICollectionViewController
super.init(coder: aDecoder)
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchSource(_:)), name: AppManager.didFetchSourceNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.importApp(_:)), name: AppDelegate.importAppDeepLinkNotification, object: nil)
}
override func viewDidLoad()
{
super.viewDidLoad()
if #available(iOS 13.0, *)
{
self.navigationItem.leftBarButtonItem?.activityIndicatorView.style = .medium
}
// Allows us to intercept delegate callbacks.
self.updatesDataSource.fetchedResultsController.delegate = self
@@ -71,7 +79,6 @@ class MyAppsViewController: UICollectionViewController
self.collectionView.prefetchDataSource = self.dataSource
self.prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
self.prototypeUpdateCell.translatesAutoresizingMaskIntoConstraints = false
self.prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
self.collectionView.register(UpdateCollectionViewCell.nib, forCellWithReuseIdentifier: "UpdateCell")
@@ -79,9 +86,13 @@ class MyAppsViewController: UICollectionViewController
self.sideloadingProgressView = UIProgressView(progressViewStyle: .bar)
self.sideloadingProgressView.translatesAutoresizingMaskIntoConstraints = false
self.sideloadingProgressView.progressTintColor = .altGreen
self.sideloadingProgressView.progressTintColor = .altPrimary
self.sideloadingProgressView.progress = 0
#if !BETA
self.navigationItem.leftBarButtonItem = nil
#endif
if let navigationBar = self.navigationController?.navigationBar
{
navigationBar.addSubview(self.sideloadingProgressView)
@@ -93,18 +104,33 @@ class MyAppsViewController: UICollectionViewController
// Gestures
self.longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(MyAppsViewController.handleLongPressGesture(_:)))
self.collectionView.addGestureRecognizer(self.longPressGestureRecognizer)
self.registerForPreviewing(with: self, sourceView: self.collectionView)
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.updateDataSource()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
guard segue.identifier == "showApp" else { return }
guard let identifier = segue.identifier else { return }
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return }
let installedApp = self.dataSource.item(at: indexPath)
let appViewController = segue.destination as! AppViewController
appViewController.app = installedApp.storeApp
switch identifier
{
case "showApp", "showUpdate":
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return }
let installedApp = self.dataSource.item(at: indexPath)
let appViewController = segue.destination as! AppViewController
appViewController.app = installedApp.storeApp
default: break
}
}
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool
@@ -134,9 +160,13 @@ private extension MyAppsViewController
dynamicDataSource.numberOfItemsHandler = { _ in self.updatesDataSource.itemCount == 0 ? 1 : 0 }
dynamicDataSource.cellIdentifierHandler = { _ in "NoUpdatesCell" }
dynamicDataSource.cellConfigurationHandler = { (cell, _, indexPath) in
cell.layer.cornerRadius = 20
cell.layer.masksToBounds = true
cell.contentView.backgroundColor = UIColor.altGreen.withAlphaComponent(0.15)
let cell = cell as! NoUpdatesCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.blurView.layer.cornerRadius = 20
cell.blurView.layer.masksToBounds = true
cell.blurView.backgroundColor = .altPrimary
}
return dynamicDataSource
@@ -152,17 +182,24 @@ private extension MyAppsViewController
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.liveFetchLimit = maximumCollapsedUpdatesCount
dataSource.cellIdentifierHandler = { _ in "UpdateCell" }
dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in
dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in
guard let self = self else { return }
guard let app = installedApp.storeApp else { return }
let cell = cell as! UpdateCollectionViewCell
cell.tintColor = app.tintColor ?? .altGreen
cell.nameLabel.text = app.name
cell.versionDescriptionTextView.text = app.versionDescription
cell.appIconImageView.image = UIImage(named: app.iconName)
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.updateButton.isIndicatingActivity = false
cell.updateButton.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
cell.tintColor = app.tintColor ?? .altPrimary
cell.versionDescriptionTextView.text = app.versionDescription
cell.bannerView.titleLabel.text = app.name
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.betaBadgeView.isHidden = !app.isBeta
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
if self.expandedAppUpdates.contains(app.bundleIdentifier)
{
@@ -176,12 +213,40 @@ private extension MyAppsViewController
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
let progress = AppManager.shared.installationProgress(for: app)
cell.updateButton.progress = progress
cell.bannerView.button.progress = progress
cell.dateLabel.text = Date().relativeDateString(since: app.versionDate, dateFormatter: self.dateFormatter)
cell.bannerView.subtitleLabel.text = Date().relativeDateString(since: app.versionDate, dateFormatter: self.dateFormatter)
cell.setNeedsLayout()
}
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in
guard let iconURL = installedApp.storeApp?.iconURL else { return nil }
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! UpdateCollectionViewCell
cell.bannerView.iconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
@@ -198,14 +263,18 @@ private extension MyAppsViewController
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellIdentifierHandler = { _ in "AppCell" }
dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in
let tintColor = installedApp.storeApp?.tintColor ?? .altGreen
let tintColor = installedApp.storeApp?.tintColor ?? .altPrimary
let cell = cell as! InstalledAppCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = tintColor
cell.appIconImageView.isIndicatingActivity = true
cell.refreshButton.isIndicatingActivity = false
cell.refreshButton.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
let currentDate = Date()
@@ -213,34 +282,34 @@ private extension MyAppsViewController
if numberOfDays == 1
{
cell.refreshButton.setTitle(NSLocalizedString("1 DAY", comment: ""), for: .normal)
cell.bannerView.button.setTitle(NSLocalizedString("1 DAY", comment: ""), for: .normal)
}
else
{
cell.refreshButton.setTitle(String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays)), for: .normal)
cell.bannerView.button.setTitle(String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays)), for: .normal)
}
cell.nameLabel.text = installedApp.name
cell.developerLabel.text = installedApp.storeApp?.developerName ?? NSLocalizedString("Sideloaded", comment: "")
cell.bannerView.titleLabel.text = installedApp.name
cell.bannerView.subtitleLabel.text = installedApp.storeApp?.developerName ?? NSLocalizedString("Sideloaded", comment: "")
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
switch numberOfDays
{
case 2...3: cell.refreshButton.tintColor = .refreshOrange
case 4...5: cell.refreshButton.tintColor = .refreshYellow
case 6...: cell.refreshButton.tintColor = .refreshGreen
default: cell.refreshButton.tintColor = .refreshRed
case 2...3: cell.bannerView.button.tintColor = .refreshOrange
case 4...5: cell.bannerView.button.tintColor = .refreshYellow
case 6...: cell.bannerView.button.tintColor = .refreshGreen
default: cell.bannerView.button.tintColor = .refreshRed
}
if let refreshGroup = self.refreshGroup, let progress = refreshGroup.progress(for: installedApp), progress.fractionCompleted < 1.0
{
cell.refreshButton.progress = progress
cell.bannerView.button.progress = progress
}
else
{
cell.refreshButton.progress = nil
cell.bannerView.button.progress = nil
}
}
dataSource.prefetchHandler = { (item, indexPath, completion) in
@@ -258,12 +327,27 @@ private extension MyAppsViewController
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! InstalledAppCollectionViewCell
cell.appIconImageView.image = image
cell.appIconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
cell.bannerView.iconImageView.isIndicatingActivity = false
}
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 == nil OR %K == NO OR %K == %@",
#keyPath(InstalledApp.storeApp),
#keyPath(InstalledApp.storeApp.isBeta),
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
}
}
}
private extension MyAppsViewController
@@ -281,9 +365,12 @@ private extension MyAppsViewController
UIApplication.shared.applicationIconBadgeNumber = 0
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: Section.updates.rawValue))
}
if self.isViewLoaded
{
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: Section.updates.rawValue))
}
}
}
func refresh(_ installedApps: [InstalledApp], completionHandler: @escaping (Result<[String : Result<InstalledApp, Error>], Error>) -> Void)
@@ -313,17 +400,20 @@ private extension MyAppsViewController
guard !failures.isEmpty else { break }
let localizedText: String
let detailText: String?
if let failure = failures.first, failures.count == 1
{
localizedText = failure.value.localizedDescription
detailText = nil
}
else
{
localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count))
detailText = failures.first?.value.localizedDescription
}
let toastView = ToastView(text: localizedText, detailText: nil)
toastView.tintColor = .refreshRed
let toastView = ToastView(text: localizedText, detailText: detailText)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
@@ -503,23 +593,88 @@ private extension MyAppsViewController
@IBAction func sideloadApp(_ sender: UIBarButtonItem)
{
func sideloadApp()
{
self.presentSideloadingAlert { (shouldContinue) in
guard shouldContinue else { return }
let iOSAppUTI = "com.apple.itunes.ipa" // Declared by the system.
let documentPickerViewController = UIDocumentPickerViewController(documentTypes: [iOSAppUTI], in: .import)
documentPickerViewController.delegate = self
self.present(documentPickerViewController, animated: true, completion: nil)
}
let alertController = UIAlertController(title: NSLocalizedString("Sideload Apps (Beta)", comment: ""), message: NSLocalizedString("You may only install 10 apps + app extensions per week due to Apple's restrictions.\n\nIf you encounter an app that is not able to be sideloaded, please report the app to riley@rileytestut.com.", comment: ""), preferredStyle: .alert)
}
func presentSideloadingAlert(completion: @escaping (Bool) -> Void)
{
let alertController = UIAlertController(title: NSLocalizedString("Sideload Apps (Beta)", comment: ""), message: NSLocalizedString("You may only install 10 apps + app extensions per week due to Apple's restrictions.\n\nIf you encounter an app that is not able to be sideloaded, please report the app to support@altstore.io.", comment: ""), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("OK"), style: .default, handler: { (action) in
sideloadApp()
completion(true)
}))
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in
completion(false)
}))
alertController.addAction(.cancel)
self.present(alertController, animated: true, completion: nil)
}
func installApp(at fileURL: URL, completion: @escaping (Result<Void, Error>) -> Void)
{
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
DispatchQueue.global().async {
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
do
{
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
let unzippedApplicationURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
guard let application = ALTApplication(fileURL: unzippedApplicationURL) else { throw OperationError.invalidApp }
self.sideloadingProgress = AppManager.shared.install(application, presentingViewController: self) { (result) in
try? FileManager.default.removeItem(at: temporaryDirectory)
DispatchQueue.main.async {
if let error = result.error
{
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
else
{
print("Successfully installed app:", application.bundleIdentifier)
}
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
self.sideloadingProgressView.observedProgress = nil
self.sideloadingProgressView.setHidden(true, animated: true)
completion(.success(()))
}
}
DispatchQueue.main.async {
self.sideloadingProgressView.progress = 0
self.sideloadingProgressView.isHidden = false
self.sideloadingProgressView.observedProgress = self.sideloadingProgress
}
}
catch
{
try? FileManager.default.removeItem(at: temporaryDirectory)
DispatchQueue.main.async {
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
completion(.failure(error))
}
}
}
@objc func presentAlert(for installedApp: InstalledApp)
{
let alertController = UIAlertController(title: nil, message: NSLocalizedString("Removing a sideloaded app only removes it from AltStore. You must also delete it from the home screen to fully uninstall the app.", comment: ""), preferredStyle: .actionSheet)
@@ -569,6 +724,41 @@ private extension MyAppsViewController
self.presentAlert(for: installedApp)
}
@objc func importApp(_ notification: Notification)
{
#if BETA
guard let fileURL = notification.userInfo?[AppDelegate.importAppDeepLinkURLKey] as? URL else { return }
guard self.presentedViewController == nil else { return }
func finish()
{
do
{
try FileManager.default.removeItem(at: fileURL)
}
catch
{
print("Unable to remove imported .ipa.", error)
}
}
self.presentSideloadingAlert { (shouldContinue) in
if shouldContinue
{
self.installApp(at: fileURL) { (result) in
finish()
}
}
else
{
finish()
}
}
#endif
}
}
extension MyAppsViewController
@@ -584,10 +774,10 @@ extension MyAppsViewController
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader", for: indexPath) as! UpdatesCollectionHeaderView
UIView.performWithoutAnimation {
headerView.button.backgroundColor = UIColor.altGreen.withAlphaComponent(0.15)
headerView.button.backgroundColor = UIColor.altPrimary.withAlphaComponent(0.15)
headerView.button.setTitle("", for: .normal)
headerView.button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 28)
headerView.button.setTitleColor(.altGreen, for: .normal)
headerView.button.setTitleColor(.altPrimary, for: .normal)
headerView.button.addTarget(self, action: #selector(MyAppsViewController.toggleAppUpdates), for: .primaryActionTriggered)
if self.isUpdateSectionCollapsed
@@ -613,7 +803,7 @@ extension MyAppsViewController
headerView.textLabel.text = NSLocalizedString("Installed", comment: "")
headerView.button.isIndicatingActivity = false
headerView.button.activityIndicatorView.color = .altGreen
headerView.button.activityIndicatorView.color = .altPrimary
headerView.button.setTitle(NSLocalizedString("Refresh All", comment: ""), for: .normal)
headerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshAllApps(_:)), for: .primaryActionTriggered)
headerView.button.isIndicatingActivity = self.isRefreshingAllApps
@@ -624,20 +814,30 @@ extension MyAppsViewController
return headerView
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let section = Section.allCases[indexPath.section]
switch section
{
case .updates:
guard let cell = collectionView.cellForItem(at: indexPath) else { break }
self.performSegue(withIdentifier: "showUpdate", sender: cell)
default: break
}
}
}
extension MyAppsViewController: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
let padding = 30 as CGFloat
let width = collectionView.bounds.width - padding
let section = Section.allCases[indexPath.section]
switch section
{
case .noUpdates:
let size = CGSize(width: width, height: 44)
let size = CGSize(width: collectionView.bounds.width, height: 44)
return size
case .updates:
@@ -648,7 +848,10 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout
return previousHeight
}
let widthConstraint = self.prototypeUpdateCell.contentView.widthAnchor.constraint(equalToConstant: width)
// Manually change cell's width to prevent conflicting with UIView-Encapsulated-Layout-Width constraints.
self.prototypeUpdateCell.frame.size.width = collectionView.bounds.width
let widthConstraint = self.prototypeUpdateCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
@@ -659,7 +862,7 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout
return size
case .installedApps:
return CGSize(width: collectionView.bounds.width, height: 60)
return CGSize(width: collectionView.bounds.width, height: 88)
}
}
@@ -677,20 +880,14 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
func collectionView(_ myCV: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
{
let section = Section.allCases[section]
switch section
{
case .noUpdates:
guard self.updatesDataSource.itemCount == 0 else { return .zero }
return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
case .updates:
guard self.updatesDataSource.itemCount > 0 else { return .zero }
return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
case .installedApps: return UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
case .noUpdates where self.updatesDataSource.itemCount != 0: return .zero
case .updates where self.updatesDataSource.itemCount == 0: return .zero
default: return UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
}
}
}
@@ -747,51 +944,42 @@ extension MyAppsViewController: UIDocumentPickerDelegate
{
guard let fileURL = urls.first else { return }
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
DispatchQueue.global().async {
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
do
{
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
let unzippedApplicationURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
guard let application = ALTApplication(fileURL: unzippedApplicationURL) else { return }
self.sideloadingProgress = AppManager.shared.install(application, presentingViewController: self) { (result) in
try? FileManager.default.removeItem(at: temporaryDirectory)
DispatchQueue.main.async {
if let error = result.error
{
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
else
{
print("Successfully installed app:", application.bundleIdentifier)
}
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
self.sideloadingProgressView.observedProgress = nil
self.sideloadingProgressView.setHidden(true, animated: true)
}
}
DispatchQueue.main.async {
self.sideloadingProgressView.progress = 0
self.sideloadingProgressView.isHidden = false
self.sideloadingProgressView.observedProgress = self.sideloadingProgress
}
}
catch
{
try? FileManager.default.removeItem(at: temporaryDirectory)
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
}
self.installApp(at: fileURL) { (result) in
print("Sideloaded app at \(fileURL) with result:", result)
}
}
}
extension MyAppsViewController: UIViewControllerPreviewingDelegate
{
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
{
guard
let indexPath = self.collectionView.indexPathForItem(at: location),
let cell = self.collectionView.cellForItem(at: indexPath)
else { return nil }
let section = Section.allCases[indexPath.section]
switch section
{
case .updates:
previewingContext.sourceRect = cell.frame
let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return nil}
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
default: return nil
}
}
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
{
let point = CGPoint(x: previewingContext.sourceRect.midX, y: previewingContext.sourceRect.midY)
guard let indexPath = self.collectionView.indexPathForItem(at: point), let cell = self.collectionView.cellForItem(at: indexPath) else { return }
self.performSegue(withIdentifier: "showUpdate", sender: cell)
}
}

View File

@@ -25,19 +25,27 @@ extension UpdateCollectionViewCell
}
}
@IBOutlet var appIconImageView: UIImageView!
@IBOutlet var nameLabel: UILabel!
@IBOutlet var dateLabel: UILabel!
@IBOutlet var updateButton: PillButton!
@IBOutlet var bannerView: AppBannerView!
@IBOutlet var versionDescriptionTitleLabel: UILabel!
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
@IBOutlet private var blurView: UIVisualEffectView!
private var originalTintColor: UIColor?
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.layer.cornerRadius = 20
self.contentView.layer.masksToBounds = true
// Prevent temporary unsatisfiable constraint errors due to UIView-Encapsulated-Layout constraints.
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
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
self.update()
}
@@ -46,6 +54,11 @@ extension UpdateCollectionViewCell
{
super.tintColorDidChange()
if self.tintAdjustmentMode != .dimmed
{
self.originalTintColor = self.tintColor
}
self.update()
}
@@ -57,6 +70,22 @@ extension UpdateCollectionViewCell
}
animator.startAnimation()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
{
let view = super.hitTest(point, with: event)
if view == self.versionDescriptionTextView
{
// Forward touches on the text view (but not on the nested "more" button)
// so cell selection works as expected.
return self
}
else
{
return view
}
}
}
private extension UpdateCollectionViewCell
@@ -69,12 +98,9 @@ private extension UpdateCollectionViewCell
case .expanded: self.versionDescriptionTextView.isCollapsed = false
}
self.versionDescriptionTitleLabel.textColor = self.tintColor
self.contentView.backgroundColor = self.tintColor.withAlphaComponent(0.1)
self.updateButton.setTitleColor(self.tintColor, for: .normal)
self.updateButton.backgroundColor = self.tintColor.withAlphaComponent(0.15)
self.updateButton.progressTintColor = self.tintColor
self.versionDescriptionTitleLabel.textColor = self.originalTintColor ?? self.tintColor
self.blurView.backgroundColor = self.originalTintColor ?? self.tintColor
self.bannerView.button.progressTintColor = self.originalTintColor ?? self.tintColor
self.setNeedsLayout()
self.layoutIfNeeded()

View File

@@ -1,125 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" 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="14490.49"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="125"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="125"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dmf-hv-bwx">
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
<view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dmf-hv-bwx">
<rect key="frame" x="16" y="0.0" width="343" height="125"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="57X-Ep-rfq">
<rect key="frame" x="20" y="20" width="340" height="93.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="H0T-dR-3In" userLabel="App Info">
<rect key="frame" x="0.0" y="0.0" width="340" height="65"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="jg6-wi-ngb" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="65" height="65"/>
<constraints>
<constraint firstAttribute="height" constant="65" id="W3C-hH-1Ii"/>
<constraint firstAttribute="width" secondItem="jg6-wi-ngb" secondAttribute="height" multiplier="1:1" id="vt3-Qt-m21"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="100" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="2Ii-Hu-4ru">
<rect key="frame" x="76" y="14" width="172" height="37"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Short" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qmI-m4-Mra">
<rect key="frame" x="0.0" y="0.0" width="172" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xaB-Kc-Par">
<rect key="frame" x="0.0" y="22.5" width="172" height="14.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="OSL-U2-BKa" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="259" y="17" width="81" 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" constant="81" id="3yj-p0-NuE"/>
<constraint firstAttribute="height" constant="31" id="KbP-M6-N3w"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="UPDATE"/>
</button>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
<rect key="frame" x="0.0" y="79" width="340" height="14.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="1000" verticalHuggingPriority="251" text="What's New" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h1u-nj-qsP">
<rect key="frame" x="0.0" y="0.0" width="65" height="13.5"/>
<constraints>
<constraint firstAttribute="width" constant="65" id="C7Y-nh-TKJ"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="11"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="75" y="-10" width="265" height="24.5"/>
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="H0T-dR-3In" firstAttribute="width" secondItem="57X-Ep-rfq" secondAttribute="width" id="DYI-fa-Egk"/>
<constraint firstItem="RSR-5W-7tt" firstAttribute="width" secondItem="57X-Ep-rfq" secondAttribute="width" id="d3x-mH-ODQ"/>
</constraints>
</stackView>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="1xN-9h-DFd">
<rect key="frame" x="0.0" y="0.0" width="343" height="125"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="9iq-CR-Xc4">
<rect key="frame" x="0.0" y="0.0" width="343" height="125"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="uYl-PH-DuP">
<rect key="frame" x="0.0" y="0.0" width="343" height="125"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Nop-pL-Icx" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="88"/>
<constraints>
<constraint firstAttribute="height" constant="88" id="EPP-7O-1Ad"/>
</constraints>
</view>
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
<rect key="frame" x="0.0" y="88" width="343" height="37"/>
<subviews>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RKU-pY-wmQ">
<rect key="frame" x="15" y="0.0" width="65" height="22"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="4GQ-XP-i7X">
<rect key="frame" x="0.0" y="0.0" width="65" height="22"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="1000" verticalHuggingPriority="251" text="What's New" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h1u-nj-qsP">
<rect key="frame" x="0.0" y="0.0" width="65" height="22"/>
<constraints>
<constraint firstAttribute="width" constant="65" id="C7Y-nh-TKJ"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="11"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="h1u-nj-qsP" firstAttribute="leading" secondItem="4GQ-XP-i7X" secondAttribute="leading" id="3cO-Mj-Yua"/>
<constraint firstAttribute="trailing" secondItem="h1u-nj-qsP" secondAttribute="trailing" id="Hek-OE-YMc"/>
<constraint firstAttribute="bottom" secondItem="h1u-nj-qsP" secondAttribute="bottom" id="bLg-Ut-aEb"/>
<constraint firstItem="h1u-nj-qsP" firstAttribute="top" secondItem="4GQ-XP-i7X" secondAttribute="top" id="beL-ob-CQ7"/>
</constraints>
</view>
<vibrancyEffect style="secondaryLabel">
<blurEffect style="systemChromeMaterial"/>
</vibrancyEffect>
</visualEffectView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="90" y="0.0" width="238" height="22"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
<edgeInsets key="layoutMargins" top="0.0" left="15" bottom="15" right="15"/>
</stackView>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" name="BlurTint"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="uYl-PH-DuP" secondAttribute="trailing" id="51O-j6-eoh"/>
<constraint firstAttribute="bottom" secondItem="uYl-PH-DuP" secondAttribute="bottom" id="IGs-MS-vnM"/>
<constraint firstItem="uYl-PH-DuP" firstAttribute="top" secondItem="9iq-CR-Xc4" secondAttribute="top" id="hnr-wG-XRY"/>
<constraint firstItem="uYl-PH-DuP" firstAttribute="leading" secondItem="9iq-CR-Xc4" secondAttribute="leading" id="usR-Ia-LMy"/>
</constraints>
</view>
<blurEffect style="systemChromeMaterial"/>
</visualEffectView>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="57X-Ep-rfq" secondAttribute="bottom" constant="20" id="ArC-R2-jtc"/>
<constraint firstItem="57X-Ep-rfq" firstAttribute="leading" secondItem="mdL-JE-wCe" secondAttribute="leading" constant="20" id="PvV-gg-7us"/>
<constraint firstItem="57X-Ep-rfq" firstAttribute="top" secondItem="dmf-hv-bwx" secondAttribute="top" constant="20" id="QHM-k8-g0x"/>
<constraint firstItem="mdL-JE-wCe" firstAttribute="trailing" secondItem="57X-Ep-rfq" secondAttribute="trailing" constant="15" id="sGL-bx-qIk"/>
<constraint firstItem="1xN-9h-DFd" firstAttribute="top" secondItem="dmf-hv-bwx" secondAttribute="top" id="6rb-Bw-UVn"/>
<constraint firstAttribute="bottom" secondItem="1xN-9h-DFd" secondAttribute="bottom" id="dnI-NB-BKv"/>
<constraint firstAttribute="trailing" secondItem="1xN-9h-DFd" secondAttribute="trailing" id="kbY-Z6-V86"/>
<constraint firstItem="1xN-9h-DFd" firstAttribute="leading" secondItem="dmf-hv-bwx" secondAttribute="leading" id="ofk-a7-m0Y"/>
</constraints>
<edgeInsets key="layoutMargins" top="20" left="20" bottom="20" right="20"/>
<viewLayoutGuide key="safeArea" id="mdL-JE-wCe"/>
</view>
</subviews>
</view>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="dmf-hv-bwx" firstAttribute="top" secondItem="Kqf-Pv-ca3" secondAttribute="top" id="7yY-05-eHt"/>
<constraint firstAttribute="bottom" secondItem="dmf-hv-bwx" secondAttribute="bottom" id="Rrx-0k-6He"/>
<constraint firstItem="dmf-hv-bwx" firstAttribute="leading" secondItem="Kqf-Pv-ca3" secondAttribute="leading" id="W0V-sT-tXo"/>
<constraint firstAttribute="trailing" secondItem="dmf-hv-bwx" secondAttribute="trailing" id="tgy-Zi-iZF"/>
<constraint firstItem="dmf-hv-bwx" firstAttribute="leading" secondItem="Kqf-Pv-ca3" secondAttribute="leadingMargin" id="W0V-sT-tXo"/>
<constraint firstAttribute="trailingMargin" secondItem="dmf-hv-bwx" secondAttribute="trailing" id="tgy-Zi-iZF"/>
</constraints>
<viewLayoutGuide key="safeArea" id="C6r-zO-INg"/>
<connections>
<outlet property="appIconImageView" destination="jg6-wi-ngb" id="j83-Dl-GT6"/>
<outlet property="dateLabel" destination="xaB-Kc-Par" id="mfG-3C-r7j"/>
<outlet property="nameLabel" destination="qmI-m4-Mra" id="LQz-w7-HNb"/>
<outlet property="updateButton" destination="OSL-U2-BKa" id="WbI-96-Nel"/>
<outlet property="bannerView" destination="Nop-pL-Icx" id="GiX-K1-5oz"/>
<outlet property="blurView" destination="1xN-9h-DFd" id="HBI-nT-xYh"/>
<outlet property="versionDescriptionTextView" destination="rNs-2O-k3V" id="4TC-A3-oxb"/>
<outlet property="versionDescriptionTitleLabel" destination="h1u-nj-qsP" id="dnz-Yv-BdY"/>
</connections>
<point key="canvasLocation" x="618.39999999999998" y="96.251874062968525"/>
</collectionViewCell>
</objects>
<resources>
<namedColor name="BlurTint">
<color red="1" green="1" blue="1" alpha="0.29999999999999999" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -0,0 +1,30 @@
//
// NewsCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
class NewsCollectionViewCell: UICollectionViewCell
{
@IBOutlet var titleLabel: UILabel!
@IBOutlet var captionLabel: UILabel!
@IBOutlet var imageView: UIImageView!
@IBOutlet var contentBackgroundView: UIView!
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.preservesSuperviewLayoutMargins = true
self.contentBackgroundView.layer.cornerRadius = 30
self.contentBackgroundView.clipsToBounds = true
self.imageView.layer.cornerRadius = 30
self.imageView.clipsToBounds = true
}
}

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="wRF-2R-NUG" customClass="NewsCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="335" height="299"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="335" height="299"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="azr-Ea-luN">
<rect key="frame" x="16" y="0.0" width="303" height="299"/>
</view>
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Xba-Qs-SQo">
<rect key="frame" x="16" y="0.0" width="303" height="299"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="tNk-9u-1tk">
<rect key="frame" x="0.0" y="0.0" width="303" height="298.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="akF-Tr-G5M">
<rect key="frame" x="0.0" y="0.0" width="303" height="117.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Delta" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="AkN-BE-I1a">
<rect key="frame" x="25" y="25" width="54.5" height="26.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" alpha="0.75" contentMode="left" horizontalHuggingPriority="251" verticalCompressionResistancePriority="999" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SHB-kk-YhL">
<rect key="frame" x="25" y="61.5" width="35.5" height="36"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="25" left="25" bottom="20" right="25"/>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" placeholderIntrinsicWidth="335" placeholderIntrinsicHeight="200" translatesAutoresizingMaskIntoConstraints="NO" id="l36-Bm-De0">
<rect key="frame" x="0.0" y="117.5" width="303" height="181"/>
<constraints>
<constraint firstAttribute="width" secondItem="l36-Bm-De0" secondAttribute="height" multiplier="67:40" priority="999" id="QGD-YE-Hw2"/>
</constraints>
</imageView>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="tNk-9u-1tk" firstAttribute="top" secondItem="Xba-Qs-SQo" secondAttribute="top" id="Dw8-lF-Fzl"/>
<constraint firstAttribute="trailing" secondItem="tNk-9u-1tk" secondAttribute="trailing" id="Zt8-Wa-oB9"/>
<constraint firstItem="tNk-9u-1tk" firstAttribute="leading" secondItem="Xba-Qs-SQo" secondAttribute="leading" id="m6p-Ee-dTh"/>
<constraint firstAttribute="bottom" secondItem="tNk-9u-1tk" secondAttribute="bottom" constant="0.5" id="v9g-yC-db9"/>
</constraints>
</view>
</subviews>
</view>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Xba-Qs-SQo" firstAttribute="top" secondItem="wRF-2R-NUG" secondAttribute="top" id="0xe-Rt-MhF"/>
<constraint firstItem="Xba-Qs-SQo" firstAttribute="leading" secondItem="wRF-2R-NUG" secondAttribute="leadingMargin" id="5MO-c0-5rG"/>
<constraint firstItem="azr-Ea-luN" firstAttribute="leading" secondItem="wRF-2R-NUG" secondAttribute="leadingMargin" id="8Ck-dI-nJy"/>
<constraint firstAttribute="trailingMargin" secondItem="Xba-Qs-SQo" secondAttribute="trailing" id="DNL-Jj-3By"/>
<constraint firstAttribute="bottom" secondItem="Xba-Qs-SQo" secondAttribute="bottom" id="Ecj-fN-hZv"/>
<constraint firstAttribute="bottom" secondItem="azr-Ea-luN" secondAttribute="bottom" priority="999" id="e56-UD-DRT"/>
<constraint firstItem="azr-Ea-luN" firstAttribute="top" secondItem="wRF-2R-NUG" secondAttribute="top" id="h2k-WE-Esg"/>
<constraint firstAttribute="trailingMargin" secondItem="azr-Ea-luN" secondAttribute="trailing" priority="999" id="hsS-zC-A58"/>
</constraints>
<connections>
<outlet property="captionLabel" destination="SHB-kk-YhL" id="zY3-qQ-9oY"/>
<outlet property="contentBackgroundView" destination="azr-Ea-luN" id="2Pl-11-YvR"/>
<outlet property="imageView" destination="l36-Bm-De0" id="3do-aQ-5r4"/>
<outlet property="titleLabel" destination="AkN-BE-I1a" id="hA2-3O-q5J"/>
</connections>
<point key="canvasLocation" x="138" y="153"/>
</collectionViewCell>
</objects>
</document>

View File

@@ -0,0 +1,478 @@
//
// NewsViewController.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import SafariServices
import Roxas
import Nuke
private class AppBannerFooterView: UICollectionReusableView
{
let bannerView = AppBannerView(frame: .zero)
let tapGestureRecognizer = UITapGestureRecognizer(target: nil, action: nil)
override init(frame: CGRect)
{
super.init(frame: frame)
self.addGestureRecognizer(self.tapGestureRecognizer)
self.bannerView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.bannerView)
NSLayoutConstraint.activate([
self.bannerView.topAnchor.constraint(equalTo: self.topAnchor),
self.bannerView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.bannerView.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor),
self.bannerView.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor)
])
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class NewsViewController: UICollectionViewController
{
private lazy var dataSource = self.makeDataSource()
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
private var prototypeCell: NewsCollectionViewCell!
private var loadingState: LoadingState = .loading {
didSet {
self.update()
}
}
// Cache
private var cachedCellSizes = [String: CGSize]()
override func viewDidLoad()
{
super.viewDidLoad()
self.prototypeCell = NewsCollectionViewCell.instantiate(with: NewsCollectionViewCell.nib!)
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
self.collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.register(AppBannerFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner")
self.registerForPreviewing(with: self, sourceView: self.collectionView)
self.update()
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.fetchSource()
}
override func viewWillLayoutSubviews()
{
super.viewWillLayoutSubviews()
if self.collectionView.contentInset.bottom != 20
{
// Triggers collection view update in iOS 13, which crashes if we do it in viewDidLoad()
// since the database might not be loaded yet.
self.collectionView.contentInset.bottom = 20
}
}
}
private extension NewsViewController
{
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>
{
let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: false)]
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.sortIndex), cacheName: nil)
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>(fetchedResultsController: fetchedResultsController)
dataSource.proxy = self
dataSource.cellConfigurationHandler = { (cell, newsItem, indexPath) in
let cell = cell as! NewsCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.titleLabel.text = newsItem.title
cell.captionLabel.text = newsItem.caption
cell.contentBackgroundView.backgroundColor = newsItem.tintColor
cell.imageView.image = nil
if newsItem.imageURL != nil
{
cell.imageView.isIndicatingActivity = true
cell.imageView.isHidden = false
}
else
{
cell.imageView.isIndicatingActivity = false
cell.imageView.isHidden = true
}
}
dataSource.prefetchHandler = { (newsItem, indexPath, completionHandler) in
guard let imageURL = newsItem.imageURL else { return nil }
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! NewsCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
dataSource.placeholderView = self.placeholderView
return dataSource
}
func fetchSource()
{
self.loadingState = .loading
AppManager.shared.fetchSource() { (result) in
do
{
let source = try result.get()
try source.managedObjectContext?.save()
DispatchQueue.main.async {
self.loadingState = .finished(.success(()))
}
}
catch
{
DispatchQueue.main.async {
if self.dataSource.itemCount > 0
{
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
self.loadingState = .finished(.failure(error))
}
}
}
}
func update()
{
switch self.loadingState
{
case .loading:
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = false
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
self.placeholderView.activityIndicatorView.startAnimating()
case .finished(.failure(let error)):
self.placeholderView.textLabel.isHidden = false
self.placeholderView.detailTextLabel.isHidden = false
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch News", comment: "")
self.placeholderView.detailTextLabel.text = error.localizedDescription
self.placeholderView.activityIndicatorView.stopAnimating()
case .finished(.success):
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = true
self.placeholderView.activityIndicatorView.stopAnimating()
}
}
}
private extension NewsViewController
{
@objc func handleTapGesture(_ gestureRecognizer: UITapGestureRecognizer)
{
guard let footerView = gestureRecognizer.view as? UICollectionReusableView else { return }
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return supplementaryView == footerView
}) else { return }
let item = self.dataSource.item(at: indexPath)
guard let storeApp = item.storeApp else { return }
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
}
@objc func performAppAction(_ sender: PillButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return supplementaryView?.frame.contains(point) ?? false
}) else { return }
let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return }
if let installedApp = app.storeApp?.installedApp
{
self.open(installedApp)
}
else
{
self.install(storeApp, at: indexPath)
}
}
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath)
{
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
DispatchQueue.main.async {
switch result
{
case .failure(OperationError.cancelled): break // Ignore
case .failure(let error):
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
case .success: print("Installed app:", storeApp.bundleIdentifier)
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
func open(_ installedApp: InstalledApp)
{
UIApplication.shared.open(installedApp.openAppURL)
}
}
extension NewsViewController
{
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let newsItem = self.dataSource.item(at: indexPath)
if let externalURL = newsItem.externalURL
{
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
self.present(safariViewController, animated: true, completion: nil)
}
else if let storeApp = newsItem.storeApp
{
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
}
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
let item = self.dataSource.item(at: indexPath)
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner", for: indexPath) as! AppBannerFooterView
guard let storeApp = item.storeApp else { return footerView }
footerView.layoutMargins.left = self.view.layoutMargins.left
footerView.layoutMargins.right = self.view.layoutMargins.right
footerView.bannerView.titleLabel.text = storeApp.name
footerView.bannerView.subtitleLabel.text = storeApp.developerName
footerView.bannerView.tintColor = storeApp.tintColor
footerView.bannerView.betaBadgeView.isHidden = !storeApp.isBeta
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
{
footerView.bannerView.button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
let progress = AppManager.shared.installationProgress(for: storeApp)
footerView.bannerView.button.progress = progress
if Date() < storeApp.versionDate
{
footerView.bannerView.button.countdownDate = storeApp.versionDate
}
else
{
footerView.bannerView.button.countdownDate = nil
}
}
else
{
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
footerView.bannerView.button.progress = nil
footerView.bannerView.button.countdownDate = nil
}
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
return footerView
}
}
extension NewsViewController: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
let item = self.dataSource.item(at: indexPath)
if let previousSize = self.cachedCellSizes[item.identifier]
{
return previousSize
}
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedCellSizes[item.identifier] = size
return size
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
{
let item = self.dataSource.item(at: IndexPath(row: 0, section: section))
if item.storeApp != nil
{
return CGSize(width: 88, height: 88)
}
else
{
return .zero
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
{
var insets = UIEdgeInsets(top: 30, left: 0, bottom: 13, right: 0)
if section == 0
{
insets.top = 10
}
return insets
}
}
extension NewsViewController: UIViewControllerPreviewingDelegate
{
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
{
if let indexPath = self.collectionView.indexPathForItem(at: location), let cell = self.collectionView.cellForItem(at: indexPath)
{
// Previewing news item.
previewingContext.sourceRect = cell.frame
let newsItem = self.dataSource.item(at: indexPath)
if let externalURL = newsItem.externalURL
{
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
return safariViewController
}
else if let storeApp = newsItem.storeApp
{
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
}
return nil
}
else
{
// Previewing app banner (or nothing).
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return layoutAttributes?.frame.contains(location) ?? false
}) else { return nil }
guard let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) else { return nil }
previewingContext.sourceRect = layoutAttributes.frame
let item = self.dataSource.item(at: indexPath)
guard let storeApp = item.storeApp else { return nil }
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
}
}
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
{
if let safariViewController = viewControllerToCommit as? SFSafariViewController
{
self.present(safariViewController, animated: true, completion: nil)
}
else
{
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
}
}
}

View File

@@ -8,7 +8,9 @@
import Foundation
import Roxas
import Network
import AltKit
import AltSign
enum AuthenticationError: LocalizedError
@@ -30,21 +32,40 @@ enum AuthenticationError: LocalizedError
}
@objc(AuthenticationOperation)
class AuthenticationOperation: ResultOperation<ALTSigner>
class AuthenticationOperation: ResultOperation<(ALTSigner, ALTAppleAPISession)>
{
let group: OperationGroup
private weak var presentingViewController: UIViewController?
private lazy var navigationController = UINavigationController()
private lazy var navigationController: UINavigationController = {
let navigationController = self.storyboard.instantiateViewController(withIdentifier: "navigationController") as! UINavigationController
if #available(iOS 13.0, *)
{
navigationController.isModalInPresentation = true
}
return navigationController
}()
private lazy var storyboard = UIStoryboard(name: "Authentication", bundle: nil)
private var appleIDPassword: String?
private var shouldShowInstructions = false
init(presentingViewController: UIViewController?)
private var signer: ALTSigner?
private var session: ALTAppleAPISession?
private let dispatchQueue = DispatchQueue(label: "com.altstore.AuthenticationOperation")
private var submitCodeAction: UIAlertAction?
init(group: OperationGroup, presentingViewController: UIViewController?)
{
self.group = group
self.presentingViewController = presentingViewController
super.init()
self.progress.totalUnitCount = 3
}
@@ -52,18 +73,25 @@ class AuthenticationOperation: ResultOperation<ALTSigner>
{
super.main()
if let error = self.group.error
{
self.finish(.failure(error))
return
}
// Sign In
self.signIn { (result) in
self.signIn() { (result) in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result
{
case .failure(let error): self.finish(.failure(error))
case .success(let account):
case .success(let account, let session):
self.session = session
self.progress.completedUnitCount += 1
// Fetch Team
self.fetchTeam(for: account) { (result) in
self.fetchTeam(for: account, session: session) { (result) in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result
@@ -73,7 +101,7 @@ class AuthenticationOperation: ResultOperation<ALTSigner>
self.progress.completedUnitCount += 1
// Fetch Certificate
self.fetchCertificate(for: team) { (result) in
self.fetchCertificate(for: team, session: session) { (result) in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result
@@ -83,7 +111,11 @@ class AuthenticationOperation: ResultOperation<ALTSigner>
self.progress.completedUnitCount += 1
let signer = ALTSigner(team: team, certificate: certificate)
self.finish(.success(signer))
self.signer = signer
self.showInstructionsIfNecessary() { (didShowInstructions) in
self.finish(.success((signer, session)))
}
}
}
}
@@ -92,7 +124,7 @@ class AuthenticationOperation: ResultOperation<ALTSigner>
}
}
override func finish(_ result: Result<ALTSigner, Error>)
override func finish(_ result: Result<(ALTSigner, ALTAppleAPISession), Error>)
{
guard !self.isFinished else { return }
@@ -102,7 +134,7 @@ class AuthenticationOperation: ResultOperation<ALTSigner>
context.performAndWait {
do
{
let signer = try result.get()
let (signer, session) = try result.get()
let altAccount = signer.team.account
// Account
@@ -138,18 +170,25 @@ class AuthenticationOperation: ResultOperation<ALTSigner>
Keychain.shared.appleIDEmailAddress = altAccount.appleID // "account" may have nil appleID since we just saved.
Keychain.shared.appleIDPassword = self.appleIDPassword
Keychain.shared.signingCertificateSerialNumber = signer.certificate.serialNumber
Keychain.shared.signingCertificatePrivateKey = signer.certificate.privateKey
Keychain.shared.signingCertificate = signer.certificate.p12Data()
Keychain.shared.signingCertificatePassword = signer.certificate.machineIdentifier
super.finish(.success(signer))
// Refresh screen must go last since a successful refresh will cause the app to quit.
self.showRefreshScreenIfNecessary() { (didShowRefreshAlert) in
super.finish(.success((signer, session)))
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
}
}
}
catch
{
super.finish(.failure(error))
}
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
}
}
}
}
@@ -161,7 +200,7 @@ private extension AuthenticationOperation
{
guard let presentingViewController = self.presentingViewController else { return false }
self.navigationController.view.tintColor = .altPurple
self.navigationController.view.tintColor = .white
if self.navigationController.viewControllers.isEmpty
{
@@ -182,18 +221,53 @@ private extension AuthenticationOperation
private extension AuthenticationOperation
{
func signIn(completionHandler: @escaping (Result<ALTAccount, Swift.Error>) -> Void)
func connect(to server: Server, completionHandler: @escaping (Result<NWConnection, Error>) -> Void)
{
let connection = NWConnection(to: .service(name: server.service.name, type: server.service.type, domain: server.service.domain, interface: nil), using: .tcp)
connection.stateUpdateHandler = { [unowned connection] (state) in
switch state
{
case .failed(let error):
print("Failed to connect to service \(server.service.name).", error)
completionHandler(.failure(ConnectionError.connectionFailed))
case .cancelled:
completionHandler(.failure(OperationError.cancelled))
case .ready:
completionHandler(.success(connection))
case .waiting: break
case .setup: break
case .preparing: break
@unknown default: break
}
}
connection.start(queue: self.dispatchQueue)
}
func signIn(completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void)
{
func authenticate()
{
DispatchQueue.main.async {
let authenticationViewController = self.storyboard.instantiateViewController(withIdentifier: "authenticationViewController") as! AuthenticationViewController
authenticationViewController.authenticationHandler = { (result) in
if let (account, password) = result
authenticationViewController.authenticationHandler = { (appleID, password, completionHandler) in
self.authenticate(appleID: appleID, password: password) { (result) in
completionHandler(result)
}
}
authenticationViewController.completionHandler = { (result) in
if let (account, session, password) = result
{
self.appleIDPassword = password
// We presented the Auth UI and the user signed in.
// In this case, we'll assume we should show the instructions again.
self.shouldShowInstructions = true
completionHandler(.success(account))
self.appleIDPassword = password
completionHandler(.success((account, session)))
}
else
{
@@ -210,24 +284,17 @@ private extension AuthenticationOperation
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword
{
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password) { (account, error) in
do
self.authenticate(appleID: appleID, password: password) { (result) in
switch result
{
case .success(let account, let session):
self.appleIDPassword = password
completionHandler(.success((account, session)))
let account = try Result(account, error).get()
completionHandler(.success(account))
}
catch ALTAppleAPIError.incorrectCredentials
{
case .failure(ALTAppleAPIError.incorrectCredentials), .failure(ALTAppleAPIError.appSpecificPasswordRequired):
authenticate()
}
catch ALTAppleAPIError.appSpecificPasswordRequired
{
authenticate()
}
catch
{
case .failure(let error):
completionHandler(.failure(error))
}
}
@@ -238,42 +305,129 @@ private extension AuthenticationOperation
}
}
func fetchTeam(for account: ALTAccount, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void)
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void)
{
func selectTeam(from teams: [ALTTeam])
{
if let team = teams.first, teams.count == 1
guard let server = self.group.server else { return completionHandler(.failure(OperationError.invalidParameters)) }
self.connect(to: server) { (result) in
switch result
{
return completionHandler(.success(team))
}
DispatchQueue.main.async {
let selectTeamViewController = self.storyboard.instantiateViewController(withIdentifier: "selectTeamViewController") as! SelectTeamViewController
selectTeamViewController.teams = teams
selectTeamViewController.selectionHandler = { (team) in
if let team = team
{
completionHandler(.success(team))
}
else
{
completionHandler(.failure(OperationError.cancelled))
}
}
case .failure(let error): completionHandler(.failure(error))
case .success(let connection):
if !self.present(selectTeamViewController)
{
completionHandler(.failure(AuthenticationError.noTeam))
let request = AnisetteDataRequest()
server.send(request, via: connection) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success:
server.receiveResponse(from: connection) { (result) in
switch result
{
case .failure(let error):
completionHandler(.failure(error))
case .success(.error(let response)):
completionHandler(.failure(response.error))
case .success(.anisetteData(let response)):
let verificationHandler: ((@escaping (String?) -> Void) -> Void)?
if let presentingViewController = self.presentingViewController
{
verificationHandler = { (completionHandler) in
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Please enter the 6-digit verification code that was sent to your Apple devices.", comment: ""),
message: nil, preferredStyle: .alert)
alertController.addTextField { (textField) in
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
textField.keyboardType = .numberPad
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationOperation.textFieldTextDidChange(_:)), name: UITextField.textDidChangeNotification, object: textField)
}
let submitAction = UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default) { (action) in
let textField = alertController.textFields?.first
let code = textField?.text ?? ""
completionHandler(code)
}
submitAction.isEnabled = false
alertController.addAction(submitAction)
self.submitCodeAction = submitAction
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { (action) in
completionHandler(nil)
})
if self.navigationController.presentingViewController != nil
{
self.navigationController.present(alertController, animated: true, completion: nil)
}
else
{
presentingViewController.present(alertController, animated: true, completion: nil)
}
}
}
}
else
{
// No view controller to present security code alert, so don't provide verificationHandler.
verificationHandler = nil
}
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: response.anisetteData,
verificationHandler: verificationHandler) { (account, session, error) in
if let account = account, let session = session
{
completionHandler(.success((account, session)))
}
else
{
completionHandler(.failure(error ?? OperationError.unknown))
}
}
case .success:
completionHandler(.failure(ALTServerError(.unknownRequest)))
}
}
}
}
}
}
ALTAppleAPI.shared.fetchTeams(for: account) { (teams, error) in
}
func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void)
{
func selectTeam(from teams: [ALTTeam])
{
if let team = teams.first(where: { $0.type == .free })
{
return completionHandler(.success(team))
}
else if let team = teams.first(where: { $0.type == .individual })
{
return completionHandler(.success(team))
}
else if let team = teams.first
{
return completionHandler(.success(team))
}
else
{
return completionHandler(.failure(AuthenticationError.noTeam))
}
}
ALTAppleAPI.shared.fetchTeams(for: account, session: session) { (teams, error) in
switch Result(teams, error)
{
case .failure(let error): completionHandler(.failure(error))
case .success(let teams):
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
if let activeTeam = DatabaseManager.shared.activeTeam(in: context), let altTeam = teams.first(where: { $0.identifier == activeTeam.identifier })
{
@@ -288,18 +442,18 @@ private extension AuthenticationOperation
}
}
func fetchCertificate(for team: ALTTeam, completionHandler: @escaping (Result<ALTCertificate, Swift.Error>) -> Void)
func fetchCertificate(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTCertificate, Swift.Error>) -> Void)
{
func requestCertificate()
{
let machineName = "AltStore - " + UIDevice.current.name
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team) { (certificate, error) in
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team, session: session) { (certificate, error) in
do
{
let certificate = try Result(certificate, error).get()
guard let privateKey = certificate.privateKey else { throw AuthenticationError.missingPrivateKey }
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
let certificates = try Result(certificates, error).get()
@@ -326,63 +480,65 @@ private extension AuthenticationOperation
func replaceCertificate(from certificates: [ALTCertificate])
{
if let certificate = certificates.first, certificates.count == 1
{
ALTAppleAPI.shared.revoke(certificate, for: team) { (success, error) in
if let error = error, !success
{
completionHandler(.failure(error))
}
else
{
requestCertificate()
}
}
return
}
guard let certificate = certificates.first else { return completionHandler(.failure(AuthenticationError.noCertificate)) }
DispatchQueue.main.async {
let replaceCertificateViewController = self.storyboard.instantiateViewController(withIdentifier: "replaceCertificateViewController") as! ReplaceCertificateViewController
replaceCertificateViewController.team = team
replaceCertificateViewController.certificates = certificates
replaceCertificateViewController.replacementHandler = { (certificate) in
if certificate != nil
{
requestCertificate()
}
else
{
completionHandler(.failure(OperationError.cancelled))
}
}
if !self.present(replaceCertificateViewController)
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in
if let error = error, !success
{
completionHandler(.failure(AuthenticationError.noCertificate))
completionHandler(.failure(error))
}
else
{
requestCertificate()
}
}
}
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
let certificates = try Result(certificates, error).get()
if
let data = Keychain.shared.signingCertificate,
let localCertificate = ALTCertificate(p12Data: data, password: nil),
let certificate = certificates.first(where: { $0.serialNumber == localCertificate.serialNumber })
{
// We have a certificate stored in the keychain and it hasn't been revoked.
localCertificate.machineIdentifier = certificate.machineIdentifier
completionHandler(.success(localCertificate))
}
else if
let serialNumber = Keychain.shared.signingCertificateSerialNumber,
let privateKey = Keychain.shared.signingCertificatePrivateKey,
let certificate = certificates.first(where: { $0.serialNumber == serialNumber })
{
// LEGACY
// We have the private key for one of the certificates, so add it to certificate and use it.
certificate.privateKey = privateKey
completionHandler(.success(certificate))
}
else if
let serialNumber = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.certificateID) as? String,
let certificate = certificates.first(where: { $0.serialNumber == serialNumber }),
let machineIdentifier = certificate.machineIdentifier,
FileManager.default.fileExists(atPath: Bundle.main.certificateURL.path),
let data = try? Data(contentsOf: Bundle.main.certificateURL),
let localCertificate = ALTCertificate(p12Data: data, password: machineIdentifier)
{
// We have an embedded certificate that hasn't been revoked.
localCertificate.machineIdentifier = machineIdentifier
completionHandler(.success(localCertificate))
}
else if certificates.isEmpty
{
// No certificates, so request a new one.
requestCertificate()
}
else
{
// We don't have private keys for any of the certificates,
// so we need to revoke one and create a new one.
replaceCertificate(from: certificates)
}
}
@@ -393,4 +549,54 @@ private extension AuthenticationOperation
}
}
func showInstructionsIfNecessary(completionHandler: @escaping (Bool) -> Void)
{
guard self.shouldShowInstructions else { return completionHandler(false) }
DispatchQueue.main.async {
let instructionsViewController = self.storyboard.instantiateViewController(withIdentifier: "instructionsViewController") as! InstructionsViewController
instructionsViewController.showsBottomButton = true
instructionsViewController.completionHandler = {
completionHandler(true)
}
if !self.present(instructionsViewController)
{
completionHandler(false)
}
}
}
func showRefreshScreenIfNecessary(completionHandler: @escaping (Bool) -> Void)
{
guard let signer = self.signer, let session = self.session else { return completionHandler(false) }
guard let application = ALTApplication(fileURL: Bundle.main.bundleURL), let provisioningProfile = application.provisioningProfile else { return completionHandler(false) }
// If we're not using the same certificate used to install AltStore, warn user that they need to refresh.
guard !provisioningProfile.certificates.contains(signer.certificate) else { return completionHandler(false) }
DispatchQueue.main.async {
let refreshViewController = self.storyboard.instantiateViewController(withIdentifier: "refreshAltStoreViewController") as! RefreshAltStoreViewController
refreshViewController.signer = signer
refreshViewController.session = session
refreshViewController.completionHandler = { _ in
completionHandler(true)
}
if !self.present(refreshViewController)
{
completionHandler(false)
}
}
}
}
extension AuthenticationOperation
{
@objc func textFieldTextDidChange(_ notification: Notification)
{
guard let textField = notification.object as? UITextField else { return }
self.submitCodeAction?.isEnabled = (textField.text ?? "").count == 6
}
}

View File

@@ -15,6 +15,7 @@ import AltSign
class DownloadAppOperation: ResultOperation<ALTApplication>
{
let app: AppProtocol
let context: AppOperationContext
private let bundleIdentifier: String
private let sourceURL: URL
@@ -22,9 +23,11 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
private let session = URLSession(configuration: .default)
init(app: AppProtocol)
init(app: AppProtocol, context: AppOperationContext)
{
self.app = app
self.context = context
self.bundleIdentifier = app.bundleIdentifier
self.sourceURL = app.url
self.destinationURL = InstalledApp.fileURL(for: app)
@@ -38,6 +41,12 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
print("Downloading App:", self.bundleIdentifier)
func finishOperation(_ result: Result<URL, Error>)

View File

@@ -14,17 +14,22 @@ class FetchSourceOperation: ResultOperation<Source>
{
let sourceURL: URL
private let session = URLSession(configuration: .default)
private let session: URLSession
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
private lazy var dateFormatter: ISO8601DateFormatter = {
let dateFormatter = ISO8601DateFormatter()
return dateFormatter
}()
init(sourceURL: URL)
{
self.sourceURL = sourceURL
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
self.session = URLSession(configuration: configuration)
}
override func main()
@@ -38,10 +43,40 @@ class FetchSourceOperation: ResultOperation<Source>
let (data, _) = try Result((data, response), error).get()
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(self.dateFormatter)
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
// Full ISO8601 Format.
self.dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withTimeZone]
if let date = self.dateFormatter.date(from: text)
{
return date
}
// Just date portion of ISO8601.
self.dateFormatter.formatOptions = [.withFullDate]
if let date = self.dateFormatter.date(from: text)
{
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.")
})
decoder.managedObjectContext = context
let source = try decoder.decode(Source.self, from: data)
if let patreonAccessToken = source.userInfo?[.patreonAccessToken]
{
Keychain.shared.patreonCreatorAccessToken = patreonAccessToken
}
#if STAGING
source.sourceURL = self.sourceURL
#endif
self.finish(.success(source))
}
catch

View File

@@ -0,0 +1,51 @@
//
// FindServerOperation.swift
// AltStore
//
// Created by Riley Testut on 9/8/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Roxas
@objc(FindServerOperation)
class FindServerOperation: ResultOperation<Server>
{
let group: OperationGroup
init(group: OperationGroup)
{
self.group = group
super.init()
}
override func main()
{
super.main()
if let error = self.group.error
{
self.finish(.failure(error))
return
}
if let server = ServerManager.shared.discoveredServers.first(where: { $0.isPreferred })
{
// Preferred server.
self.finish(.success(server))
}
else if let server = ServerManager.shared.discoveredServers.first
{
// Any available server.
self.finish(.success(server))
}
else
{
// No servers.
self.finish(.failure(ConnectionError.serverNotFound))
}
}
}

View File

@@ -18,6 +18,8 @@ class InstallAppOperation: ResultOperation<InstalledApp>
{
let context: AppOperationContext
private var didCleanUp = false
init(context: AppOperationContext)
{
self.context = context
@@ -65,6 +67,9 @@ class InstallAppOperation: ResultOperation<InstalledApp>
installedApp.expirationDate = profile.expirationDate
}
// Temporary directory and resigned .ipa no longer needed, so delete them now to ensure AltStore doesn't quit before we get the chance to.
self.cleanUp()
self.context.group.beginInstallationHandler?(installedApp)
let request = BeginInstallationRequest()
@@ -92,27 +97,43 @@ class InstallAppOperation: ResultOperation<InstalledApp>
}
}
override func finish(_ result: Result<InstalledApp, Error>)
{
self.cleanUp()
super.finish(result)
}
}
private extension InstallAppOperation
{
func receive(from connection: NWConnection, server: Server, completionHandler: @escaping (Result<Void, Error>) -> Void)
{
server.receive(ServerResponse.self, from: connection) { (result) in
server.receiveResponse(from: connection) { (result) in
do
{
let response = try result.get()
print(response)
if let error = response.error
switch response
{
completionHandler(.failure(error))
}
else if response.progress == 1.0
{
self.progress.completedUnitCount = self.progress.totalUnitCount
completionHandler(.success(()))
}
else
{
self.progress.completedUnitCount = Int64(response.progress * 100)
self.receive(from: connection, server: server, completionHandler: completionHandler)
case .installationProgress(let response):
if response.progress == 1.0
{
self.progress.completedUnitCount = self.progress.totalUnitCount
completionHandler(.success(()))
}
else
{
self.progress.completedUnitCount = Int64(response.progress * 100)
self.receive(from: connection, server: server, completionHandler: completionHandler)
}
case .error(let response):
completionHandler(.failure(response.error))
default:
completionHandler(.failure(ALTServerError(.unknownRequest)))
}
}
catch
@@ -121,4 +142,25 @@ class InstallAppOperation: ResultOperation<InstalledApp>
}
}
}
func cleanUp()
{
guard !self.didCleanUp else { return }
self.didCleanUp = true
do
{
try FileManager.default.removeItem(at: self.context.temporaryDirectory)
if let app = self.context.app
{
let fileURL = InstalledApp.refreshedIPAURL(for: app)
try FileManager.default.removeItem(at: fileURL)
}
}
catch
{
print("Failed to remove temporary directory.", error)
}
}
}

View File

@@ -18,6 +18,8 @@ class OperationGroup
var completionHandler: ((Result<[String: Result<InstalledApp, Error>], Error>) -> Void)?
var beginInstallationHandler: ((InstalledApp) -> Void)?
var session: ALTAppleAPISession?
var server: Server?
var signer: ALTSigner?
@@ -73,7 +75,12 @@ class OperationGroup
func progress(for app: AppProtocol) -> Progress?
{
let progress = self.progressByBundleIdentifier[app.bundleIdentifier]
return self.progress(forAppWithBundleIdentifier: app.bundleIdentifier)
}
func progress(forAppWithBundleIdentifier bundleIdentifier: String) -> Progress?
{
let progress = self.progressByBundleIdentifier[bundleIdentifier]
return progress
}
}

View File

@@ -37,15 +37,16 @@ class ResignAppOperation: ResultOperation<ALTApplication>
guard
let app = self.context.app,
let signer = self.context.group.signer
let signer = self.context.group.signer,
let session = self.context.group.session
else { return self.finish(.failure(OperationError.invalidParameters)) }
// Register Device
self.registerCurrentDevice(for: signer.team) { (result) in
self.registerCurrentDevice(for: signer.team, session: session) { (result) in
guard let _ = self.process(result) else { return }
// Prepare Provisioning Profiles
self.prepareProvisioningProfiles(app.fileURL, team: signer.team) { (result) in
self.prepareProvisioningProfiles(app.fileURL, team: signer.team, session: session) { (result) in
guard let profiles = self.process(result) else { return }
// Prepare app bundle
@@ -104,13 +105,13 @@ class ResignAppOperation: ResultOperation<ALTApplication>
private extension ResignAppOperation
{
func registerCurrentDevice(for team: ALTTeam, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
func registerCurrentDevice(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
{
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else {
return completionHandler(.failure(OperationError.unknownUDID))
}
ALTAppleAPI.shared.fetchDevices(for: team) { (devices, error) in
ALTAppleAPI.shared.fetchDevices(for: team, session: session) { (devices, error) in
do
{
let devices = try Result(devices, error).get()
@@ -121,7 +122,7 @@ private extension ResignAppOperation
}
else
{
ALTAppleAPI.shared.registerDevice(name: UIDevice.current.name, identifier: udid, team: team) { (device, error) in
ALTAppleAPI.shared.registerDevice(name: UIDevice.current.name, identifier: udid, team: team, session: session) { (device, error) in
completionHandler(Result(device, error))
}
}
@@ -133,7 +134,7 @@ private extension ResignAppOperation
}
}
func prepareProvisioningProfiles(_ fileURL: URL, team: ALTTeam, completionHandler: @escaping (Result<[String: ALTProvisioningProfile], Error>) -> Void)
func prepareProvisioningProfiles(_ fileURL: URL, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<[String: ALTProvisioningProfile], Error>) -> Void)
{
guard let bundle = Bundle(url: fileURL), let app = ALTApplication(fileURL: fileURL) else { return completionHandler(.failure(OperationError.invalidApp)) }
@@ -144,7 +145,7 @@ private extension ResignAppOperation
dispatchGroup.enter()
self.prepareProvisioningProfile(for: app, team: team) { (result) in
self.prepareProvisioningProfile(for: app, team: team, session: session) { (result) in
switch result
{
case .failure(let e): error = e
@@ -162,7 +163,7 @@ private extension ResignAppOperation
dispatchGroup.enter()
self.prepareProvisioningProfile(for: appExtension, team: team) { (result) in
self.prepareProvisioningProfile(for: appExtension, team: team, session: session) { (result) in
switch result
{
case .failure(let e): error = e
@@ -186,31 +187,31 @@ private extension ResignAppOperation
}
}
func prepareProvisioningProfile(for app: ALTApplication, team: ALTTeam, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
func prepareProvisioningProfile(for app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
// Register
self.register(app, team: team) { (result) in
self.register(app, team: team, session: session) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let appID):
// Update features
self.updateFeatures(for: appID, app: app, team: team) { (result) in
self.updateFeatures(for: appID, app: app, team: team, session: session) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let appID):
// Update app groups
self.updateAppGroups(for: appID, app: app, team: team) { (result) in
self.updateAppGroups(for: appID, app: app, team: team, session: session) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let appID):
// Fetch Provisioning Profile
self.fetchProvisioningProfile(for: appID, team: team) { (result) in
self.fetchProvisioningProfile(for: appID, team: team, session: session) { (result) in
completionHandler(result)
}
}
@@ -221,12 +222,12 @@ private extension ResignAppOperation
}
}
func register(_ app: ALTApplication, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
func register(_ app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
let appName = app.name
let bundleID = "com.\(team.identifier).\(app.bundleIdentifier)"
ALTAppleAPI.shared.fetchAppIDs(for: team) { (appIDs, error) in
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { (appIDs, error) in
do
{
let appIDs = try Result(appIDs, error).get()
@@ -237,7 +238,7 @@ private extension ResignAppOperation
}
else
{
ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team) { (appID, error) in
ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team, session: session) { (appID, error) in
completionHandler(Result(appID, error))
}
}
@@ -249,10 +250,10 @@ private extension ResignAppOperation
}
}
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
let requiredFeatures = app.entitlements.compactMap { (entitlement, value) -> (ALTFeature, Any)? in
guard let feature = ALTFeature(entitlement) else { return nil }
guard let feature = ALTFeature(entitlement: entitlement) else { return nil }
return (feature, value)
}
@@ -266,12 +267,12 @@ private extension ResignAppOperation
let appID = appID.copy() as! ALTAppID
appID.features = features
ALTAppleAPI.shared.update(appID, team: team) { (appID, error) in
ALTAppleAPI.shared.update(appID, team: team, session: session) { (appID, error) in
completionHandler(Result(appID, error))
}
}
func updateAppGroups(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
func updateAppGroups(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
// TODO: Handle apps belonging to more than one app group.
guard let applicationGroups = app.entitlements[.appGroups] as? [String], let groupIdentifier = applicationGroups.first else {
@@ -287,7 +288,7 @@ private extension ResignAppOperation
// Assign App Group
// TODO: Determine whether app already belongs to app group.
ALTAppleAPI.shared.add(appID, to: group, team: team) { (success, error) in
ALTAppleAPI.shared.add(appID, to: group, team: team, session: session) { (success, error) in
let result = result.map { _ in appID }
completionHandler(result)
}
@@ -296,7 +297,7 @@ private extension ResignAppOperation
let adjustedGroupIdentifier = "group.\(team.identifier)." + groupIdentifier
ALTAppleAPI.shared.fetchAppGroups(for: team) { (groups, error) in
ALTAppleAPI.shared.fetchAppGroups(for: team, session: session) { (groups, error) in
switch Result(groups, error)
{
case .failure(let error): completionHandler(.failure(error))
@@ -311,7 +312,7 @@ private extension ResignAppOperation
// Not all characters are allowed in group names, so we replace periods with spaces (like Apple does).
let name = "AltStore " + groupIdentifier.replacingOccurrences(of: ".", with: " ")
ALTAppleAPI.shared.addAppGroup(withName: name, groupIdentifier: adjustedGroupIdentifier, team: team) { (group, error) in
ALTAppleAPI.shared.addAppGroup(withName: name, groupIdentifier: adjustedGroupIdentifier, team: team, session: session) { (group, error) in
finish(Result(group, error))
}
}
@@ -319,23 +320,23 @@ private extension ResignAppOperation
}
}
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team) { (profile, error) in
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team, session: session) { (profile, error) in
switch Result(profile, error)
{
case .failure(let error): completionHandler(.failure(error))
case .success(let profile):
// Delete existing profile
ALTAppleAPI.shared.delete(profile, for: team) { (success, error) in
ALTAppleAPI.shared.delete(profile, for: team, session: session) { (success, error) in
switch Result(success, error)
{
case .failure(let error): completionHandler(.failure(error))
case .success:
// Fetch new provisiong profile
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team) { (profile, error) in
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team, session: session) { (profile, error) in
completionHandler(Result(profile, error))
}
}
@@ -395,10 +396,26 @@ private extension ResignAppOperation
var additionalValues: [String: Any] = [Bundle.Info.urlTypes: allURLSchemes]
if self.context.bundleIdentifier == StoreApp.altstoreAppID
if self.context.bundleIdentifier == StoreApp.altstoreAppID || self.context.bundleIdentifier == StoreApp.alternativeAltStoreAppID
{
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw OperationError.unknownUDID }
additionalValues[Bundle.Info.deviceID] = udid
additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
if
let data = Keychain.shared.signingCertificate,
let signingCertificate = ALTCertificate(p12Data: data, password: nil),
let encryptingPassword = Keychain.shared.signingCertificatePassword
{
additionalValues[Bundle.Info.certificateID] = signingCertificate.serialNumber
let encryptedData = signingCertificate.encryptedP12Data(withPassword: encryptingPassword)
try encryptedData?.write(to: appBundle.certificateURL, options: .atomic)
}
else
{
// The embedded certificate + certificate identifier are already in app bundle, no need to update them.
}
}
// Prepare app

View File

@@ -0,0 +1,27 @@
//
// Benefit.swift
// AltStore
//
// Created by Riley Testut on 8/21/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
extension PatreonAPI
{
struct BenefitResponse: Decodable
{
var id: String
}
}
struct Benefit: Hashable
{
var type: ALTPatreonBenefitType
init(response: PatreonAPI.BenefitResponse)
{
self.type = ALTPatreonBenefitType(response.id)
}
}

View File

@@ -0,0 +1,27 @@
//
// Campaign.swift
// AltStore
//
// Created by Riley Testut on 8/21/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
extension PatreonAPI
{
struct CampaignResponse: Decodable
{
var id: String
}
}
struct Campaign
{
var identifier: String
init(response: PatreonAPI.CampaignResponse)
{
self.identifier = response.id
}
}

View File

@@ -0,0 +1,403 @@
//
// PatreonAPI.swift
// AltStore
//
// Created by Riley Testut on 8/20/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import AuthenticationServices
private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2"
private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt"
private let campaignID = "2863968"
extension PatreonAPI
{
enum Error: LocalizedError
{
case unknown
case notAuthenticated
case invalidAccessToken
var errorDescription: String? {
switch self
{
case .unknown: return NSLocalizedString("An unknown error occurred.", comment: "")
case .notAuthenticated: return NSLocalizedString("No connected Patreon account.", comment: "")
case .invalidAccessToken: return NSLocalizedString("Invalid access token.", comment: "")
}
}
}
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.")
}
}
}
}
class PatreonAPI: NSObject
{
static let shared = PatreonAPI()
var isAuthenticated: Bool {
return Keychain.shared.patreonAccessToken != nil
}
private var authenticationSession: ASWebAuthenticationSession?
private let session = URLSession(configuration: .ephemeral)
private let baseURL = URL(string: "https://www.patreon.com/")!
private override init()
{
super.init()
}
}
extension PatreonAPI
{
func authenticate(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
{
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")]
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()
guard
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }),
let code = codeQueryItem.value
else { throw Error.unknown }
self.fetchAccessToken(oauthCode: code) { (result) in
switch result
{
case .failure(let error): completion(.failure(error))
case .success(let accessToken, let refreshToken):
Keychain.shared.patreonAccessToken = accessToken
Keychain.shared.patreonRefreshToken = refreshToken
self.fetchAccount(completion: completion)
}
}
}
catch
{
completion(.failure(error))
}
}
if #available(iOS 13.0, *)
{
self.authenticationSession?.presentationContextProvider = self
}
self.authenticationSession?.start()
}
func fetchAccount(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
{
var components = URLComponents(string: "/api/oauth2/v2/identity")!
components.queryItems = [URLQueryItem(name: "include", value: "memberships"),
URLQueryItem(name: "fields[user]", value: "first_name,full_name"),
URLQueryItem(name: "fields[member]", value: "full_name,patron_status")]
let requestURL = components.url(relativeTo: self.baseURL)!
let request = URLRequest(url: requestURL)
self.send(request, authorizationType: .user) { (result: Result<AccountResponse, Swift.Error>) in
switch result
{
case .failure(Error.notAuthenticated):
self.signOut() { (result) in
completion(.failure(Error.notAuthenticated))
}
case .failure(let error): completion(.failure(error))
case .success(let response):
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let account = PatreonAccount(response: response, context: context)
completion(.success(account))
}
}
}
}
func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void)
{
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(campaignID)/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: "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
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)
}
return patron
}.filter { $0.benefits.contains(where: { $0.type == .credits }) }
allPatrons.append(contentsOf: patrons)
if let nextURL = response.links?["next"]
{
fetchPatrons(url: nextURL)
}
else
{
completion(.success(allPatrons))
}
}
}
}
fetchPatrons(url: requestURL)
}
func signOut(completion: @escaping (Result<Void, Swift.Error>) -> Void)
{
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let accounts = PatreonAccount.all(in: context)
accounts.forEach(context.delete(_:))
do
{
try context.save()
Keychain.shared.patreonAccessToken = nil
Keychain.shared.patreonRefreshToken = nil
completion(.success(()))
}
catch
{
completion(.failure(error))
}
}
}
}
extension PatreonAPI
{
func refreshPatreonAccount()
{
guard PatreonAPI.shared.isAuthenticated else { return }
PatreonAPI.shared.fetchAccount { (result: Result<PatreonAccount, Swift.Error>) in
do
{
let account = try result.get()
try account.managedObjectContext?.save()
}
catch
{
print("Failed to fetch Patreon account.", error)
}
}
}
}
private extension PatreonAPI
{
func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void)
{
let encodedRedirectURI = ("https://rileytestut.com/patreon/altstore" as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
let encodedOauthCode = (oauthCode as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
let body = "code=\(encodedOauthCode)&grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(encodedRedirectURI)"
let requestURL = URL(string: "/api/oauth2/token", relativeTo: self.baseURL)!
var request = URLRequest(url: requestURL)
request.httpMethod = "POST"
request.httpBody = body.data(using: .utf8)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
struct Response: Decodable
{
var access_token: String
var refresh_token: String
}
self.send(request, authorizationType: .none) { (result: Result<Response, Swift.Error>) in
switch result
{
case .failure(let error): completion(.failure(error))
case .success(let response): completion(.success((response.access_token, response.refresh_token)))
}
}
}
func refreshAccessToken(completion: @escaping (Result<Void, Swift.Error>) -> Void)
{
guard let refreshToken = Keychain.shared.patreonRefreshToken else { return }
var components = URLComponents(string: "/api/oauth2/token")!
components.queryItems = [URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "client_secret", value: clientSecret)]
let requestURL = components.url(relativeTo: self.baseURL)!
var request = URLRequest(url: requestURL)
request.httpMethod = "POST"
struct Response: Decodable
{
var access_token: String
var refresh_token: String
}
self.send(request, authorizationType: .none) { (result: Result<Response, Swift.Error>) in
switch result
{
case .failure(let error): completion(.failure(error))
case .success(let response):
Keychain.shared.patreonAccessToken = response.access_token
Keychain.shared.patreonRefreshToken = response.refresh_token
completion(.success(()))
}
}
}
func send<ResponseType: Decodable>(_ request: URLRequest, authorizationType: AuthorizationType, completion: @escaping (Result<ResponseType, Swift.Error>) -> Void)
{
var request = request
switch authorizationType
{
case .none: break
case .creator:
guard let creatorAccessToken = Keychain.shared.patreonCreatorAccessToken else { return completion(.failure(Error.invalidAccessToken)) }
request.setValue("Bearer " + creatorAccessToken, forHTTPHeaderField: "Authorization")
case .user:
guard let accessToken = Keychain.shared.patreonAccessToken else { return completion(.failure(Error.notAuthenticated)) }
request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
}
let task = self.session.dataTask(with: request) { (data, response, error) in
do
{
let data = try Result(data, error).get()
if let response = response as? HTTPURLResponse, response.statusCode == 401
{
switch authorizationType
{
case .creator: completion(.failure(Error.invalidAccessToken))
case .none: completion(.failure(Error.notAuthenticated))
case .user:
self.refreshAccessToken() { (result) in
switch result
{
case .failure(let error): completion(.failure(error))
case .success: self.send(request, authorizationType: authorizationType, completion: completion)
}
}
}
return
}
let response = try JSONDecoder().decode(ResponseType.self, from: data)
completion(.success(response))
}
catch let error
{
completion(.failure(error))
}
}
task.resume()
}
}
@available(iOS 13.0, *)
extension PatreonAPI: ASWebAuthenticationPresentationContextProviding
{
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor
{
return UIApplication.shared.keyWindow ?? UIWindow()
}
}

View File

@@ -0,0 +1,78 @@
//
// Patron.swift
// AltStore
//
// Created by Riley Testut on 8/21/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
extension PatreonAPI
{
struct PatronResponse: Decodable
{
struct Attributes: Decodable
{
var full_name: String
var patron_status: String?
}
struct Relationships: 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?
}
}
extension Patron
{
enum Status: String, Decodable
{
case active = "active_patron"
case declined = "declined_patron"
case former = "former_patron"
case unknown = "unknown"
}
}
class Patron
{
var name: String
var identifier: String
var status: Status
var benefits: Set<Benefit> = []
init(response: PatreonAPI.PatronResponse)
{
self.name = response.attributes.full_name
self.identifier = response.id
if let status = response.attributes.patron_status
{
self.status = Status(rawValue: status) ?? .unknown
}
else
{
self.status = .unknown
}
}
}

View File

@@ -0,0 +1,50 @@
//
// Tier.swift
// AltStore
//
// Created by Riley Testut on 8/21/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
extension PatreonAPI
{
struct TierResponse: Decodable
{
struct Attributes: Decodable
{
var title: String
}
struct Relationships: Decodable
{
struct Benefits: Decodable
{
var data: [BenefitResponse]
}
var benefits: Benefits
}
var id: String
var attributes: Attributes
var relationships: Relationships
}
}
struct Tier
{
var name: String
var identifier: String
var benefits: [Benefit] = []
init(response: PatreonAPI.TierResponse)
{
self.name = response.attributes.title
self.identifier = response.id
self.benefits = response.relationships.benefits.data.map(Benefit.init(response:))
}
}

View File

@@ -1,78 +0,0 @@
{
"name": "AltStore",
"identifier": "com.rileytestut.AltStore",
"sourceURL": "https://www.dropbox.com/s/6qi1vt6hsi88lv6/Apps-Dev.json?dl=1",
"apps": [
{
"name": "AltStore",
"bundleIdentifier": "com.rileytestut.AltStore",
"developerName": "Riley Testut",
"version": "0.2",
"versionDate": "2019-07-31",
"versionDescription": "AltStore has been updated with bug fixes and improvements and other nice goodies for you to enjoy.",
"downloadURL": "https://www.dropbox.com/s/w1gn9iztlqvltyp/AltStore.ipa?dl=1",
"localizedDescription": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed enim ut sem viverra aliquet eget sit amet. Viverra mauris in aliquam sem fringilla ut. Egestas erat imperdiet sed euismod nisi porta. Sit amet dictum sit amet justo donec enim diam vulputate. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Elit pellentesque habitant morbi tristique. Ut lectus arcu bibendum at. Ullamcorper a lacus vestibulum sed. Mi tempus imperdiet nulla malesuada pellentesque elit eget gravida. Nec feugiat nisl pretium fusce id velit ut. Amet nulla facilisi morbi tempus. Ut sem nulla pharetra diam sit amet nisl.\n\nTortor at auctor urna nunc id cursus metus. Commodo ullamcorper a lacus vestibulum sed arcu non odio. Faucibus turpis in eu mi bibendum neque egestas. Auctor augue mauris augue neque gravida in fermentum et sollicitudin. Aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus. Placerat in egestas erat imperdiet sed euismod nisi. Aliquam id diam maecenas ultricies mi eget mauris. Nunc faucibus a pellentesque sit amet porttitor eget dolor. Sed faucibus turpis in eu mi. Tortor vitae purus faucibus ornare suspendisse sed nisi lacus sed. Id semper risus in hendrerit gravida rutrum quisque. At lectus urna duis convallis convallis. Egestas maecenas pharetra convallis posuere. Id velit ut tortor pretium viverra. Quam pellentesque nec nam aliquam sem et tortor consequat. Risus pretium quam vulputate dignissim. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar.\n\nTempor orci eu lobortis elementum nibh tellus. Mattis rhoncus urna neque viverra justo nec. Maecenas pharetra convallis posuere morbi leo. Rhoncus mattis rhoncus urna neque viverra justo nec. Gravida dictum fusce ut placerat orci nulla pellentesque dignissim enim. At imperdiet dui accumsan sit amet. Elit sed vulputate mi sit amet mauris commodo. Pellentesque habitant morbi tristique senectus. Tortor id aliquet lectus proin nibh. Magna etiam tempor orci eu lobortis elementum. Est pellentesque elit ullamcorper dignissim. Dapibus ultrices in iaculis nunc. Commodo quis imperdiet massa tincidunt nunc pulvinar sapien et ligula. Diam vel quam elementum pulvinar. Vel turpis nunc eget lorem dolor sed viverra. Tortor pretium viverra suspendisse potenti nullam ac tortor vitae. Euismod nisi porta lorem mollis. Massa id neque aliquam vestibulum morbi blandit.\n\nMassa massa ultricies mi quis hendrerit dolor magna eget est. Augue interdum velit euismod in pellentesque massa. Sed risus ultricies tristique nulla aliquet enim. Risus viverra adipiscing at in tellus. Donec adipiscing tristique risus nec feugiat. Eget sit amet tellus cras adipiscing enim eu turpis. Auctor neque vitae tempus quam pellentesque nec. Sit amet tellus cras adipiscing enim eu turpis egestas. Dui faucibus in ornare quam viverra. Fermentum iaculis eu non diam phasellus vestibulum lorem. Odio ut enim blandit volutpat maecenas. Dolor sit amet consectetur adipiscing elit pellentesque habitant morbi tristique. Pellentesque diam volutpat commodo sed egestas egestas. Aliquam purus sit amet luctus venenatis lectus magna fringilla. Viverra mauris in aliquam sem fringilla ut morbi tincidunt. Elit duis tristique sollicitudin nibh sit. Fermentum dui faucibus in ornare quam viverra orci sagittis. Aliquet eget sit amet tellus cras adipiscing.",
"iconName": "AppIcon",
"size": 10010524,
"permissions": [
{
"type": "background-fetch",
"usageDescription": "AltStore periodically refreshes apps in the background to prevent them from expiring."
},
{
"type": "background-audio",
"usageDescription": "Allows AltStore to run longer than 30 seconds when refreshing apps in background."
}
]
},
{
"name": "Delta",
"bundleIdentifier": "com.rileytestut.Delta",
"developerName": "Riley Testut",
"subtitle": "Classic games in your pocket.",
"version": "0.82",
"versionDate": "2019-07-31",
"versionDescription": "Finally, after almost 5 years of waiting, Delta is out of beta and ready for everyone to enjoy!\n\nCurrently supports NES, SNES, N64, GB(C), and GBA games, with more to come in the future.",
"downloadURL": "https://www.dropbox.com/s/31i4hcqnorucrxi/Delta.ipa?dl=1",
"localizedDescription": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed enim ut sem viverra aliquet eget sit amet. Viverra mauris in aliquam sem fringilla ut. Egestas erat imperdiet sed euismod nisi porta. Sit amet dictum sit amet justo donec enim diam vulputate. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Elit pellentesque habitant morbi tristique. Ut lectus arcu bibendum at. Ullamcorper a lacus vestibulum sed. Mi tempus imperdiet nulla malesuada pellentesque elit eget gravida. Nec feugiat nisl pretium fusce id velit ut. Amet nulla facilisi morbi tempus. Ut sem nulla pharetra diam sit amet nisl.\n\nTortor at auctor urna nunc id cursus metus. Commodo ullamcorper a lacus vestibulum sed arcu non odio. Faucibus turpis in eu mi bibendum neque egestas. Auctor augue mauris augue neque gravida in fermentum et sollicitudin. Aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus. Placerat in egestas erat imperdiet sed euismod nisi. Aliquam id diam maecenas ultricies mi eget mauris. Nunc faucibus a pellentesque sit amet porttitor eget dolor. Sed faucibus turpis in eu mi. Tortor vitae purus faucibus ornare suspendisse sed nisi lacus sed. Id semper risus in hendrerit gravida rutrum quisque. At lectus urna duis convallis convallis. Egestas maecenas pharetra convallis posuere. Id velit ut tortor pretium viverra. Quam pellentesque nec nam aliquam sem et tortor consequat. Risus pretium quam vulputate dignissim. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar.\n\nTempor orci eu lobortis elementum nibh tellus. Mattis rhoncus urna neque viverra justo nec. Maecenas pharetra convallis posuere morbi leo. Rhoncus mattis rhoncus urna neque viverra justo nec. Gravida dictum fusce ut placerat orci nulla pellentesque dignissim enim. At imperdiet dui accumsan sit amet. Elit sed vulputate mi sit amet mauris commodo. Pellentesque habitant morbi tristique senectus. Tortor id aliquet lectus proin nibh. Magna etiam tempor orci eu lobortis elementum. Est pellentesque elit ullamcorper dignissim. Dapibus ultrices in iaculis nunc. Commodo quis imperdiet massa tincidunt nunc pulvinar sapien et ligula. Diam vel quam elementum pulvinar. Vel turpis nunc eget lorem dolor sed viverra. Tortor pretium viverra suspendisse potenti nullam ac tortor vitae. Euismod nisi porta lorem mollis. Massa id neque aliquam vestibulum morbi blandit.\n\nMassa massa ultricies mi quis hendrerit dolor magna eget est. Augue interdum velit euismod in pellentesque massa. Sed risus ultricies tristique nulla aliquet enim. Risus viverra adipiscing at in tellus. Donec adipiscing tristique risus nec feugiat. Eget sit amet tellus cras adipiscing enim eu turpis. Auctor neque vitae tempus quam pellentesque nec. Sit amet tellus cras adipiscing enim eu turpis egestas. Dui faucibus in ornare quam viverra. Fermentum iaculis eu non diam phasellus vestibulum lorem. Odio ut enim blandit volutpat maecenas. Dolor sit amet consectetur adipiscing elit pellentesque habitant morbi tristique. Pellentesque diam volutpat commodo sed egestas egestas. Aliquam purus sit amet luctus venenatis lectus magna fringilla. Viverra mauris in aliquam sem fringilla ut morbi tincidunt. Elit duis tristique sollicitudin nibh sit. Fermentum dui faucibus in ornare quam viverra orci sagittis. Aliquet eget sit amet tellus cras adipiscing.",
"iconName": "DeltaIcon",
"tintColor": "8A28F7",
"size": 26908804,
"permissions": [
{
"type": "photos",
"usageDescription": "Allows Delta to use images from your Photo Library as game artwork."
}
],
"screenshotNames": [
"Delta1",
"Delta2",
"Delta3"
]
},
{
"name": "Clip",
"bundleIdentifier": "com.rileytestut.Clip",
"subtitle": "Manage your clipboard history with ease.",
"developerName": "Riley Testut",
"version": "0.2",
"versionDate": "2019-07-31",
"versionDescription": "Bug fixes and improvements.",
"downloadURL": "https://www.dropbox.com/s/x11b4m8jvmz6tpl/Clip.ipa?dl=1",
"localizedDescription": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed enim ut sem viverra aliquet eget sit amet. Viverra mauris in aliquam sem fringilla ut. Egestas erat imperdiet sed euismod nisi porta. Sit amet dictum sit amet justo donec enim diam vulputate. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Elit pellentesque habitant morbi tristique. Ut lectus arcu bibendum at. Ullamcorper a lacus vestibulum sed. Mi tempus imperdiet nulla malesuada pellentesque elit eget gravida. Nec feugiat nisl pretium fusce id velit ut. Amet nulla facilisi morbi tempus. Ut sem nulla pharetra diam sit amet nisl.\n\nTortor at auctor urna nunc id cursus metus. Commodo ullamcorper a lacus vestibulum sed arcu non odio. Faucibus turpis in eu mi bibendum neque egestas. Auctor augue mauris augue neque gravida in fermentum et sollicitudin. Aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus. Placerat in egestas erat imperdiet sed euismod nisi. Aliquam id diam maecenas ultricies mi eget mauris. Nunc faucibus a pellentesque sit amet porttitor eget dolor. Sed faucibus turpis in eu mi. Tortor vitae purus faucibus ornare suspendisse sed nisi lacus sed. Id semper risus in hendrerit gravida rutrum quisque. At lectus urna duis convallis convallis. Egestas maecenas pharetra convallis posuere. Id velit ut tortor pretium viverra. Quam pellentesque nec nam aliquam sem et tortor consequat. Risus pretium quam vulputate dignissim. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar.\n\nTempor orci eu lobortis elementum nibh tellus. Mattis rhoncus urna neque viverra justo nec. Maecenas pharetra convallis posuere morbi leo. Rhoncus mattis rhoncus urna neque viverra justo nec. Gravida dictum fusce ut placerat orci nulla pellentesque dignissim enim. At imperdiet dui accumsan sit amet. Elit sed vulputate mi sit amet mauris commodo. Pellentesque habitant morbi tristique senectus. Tortor id aliquet lectus proin nibh. Magna etiam tempor orci eu lobortis elementum. Est pellentesque elit ullamcorper dignissim. Dapibus ultrices in iaculis nunc. Commodo quis imperdiet massa tincidunt nunc pulvinar sapien et ligula. Diam vel quam elementum pulvinar. Vel turpis nunc eget lorem dolor sed viverra. Tortor pretium viverra suspendisse potenti nullam ac tortor vitae. Euismod nisi porta lorem mollis. Massa id neque aliquam vestibulum morbi blandit.\n\nMassa massa ultricies mi quis hendrerit dolor magna eget est. Augue interdum velit euismod in pellentesque massa. Sed risus ultricies tristique nulla aliquet enim. Risus viverra adipiscing at in tellus. Donec adipiscing tristique risus nec feugiat. Eget sit amet tellus cras adipiscing enim eu turpis. Auctor neque vitae tempus quam pellentesque nec. Sit amet tellus cras adipiscing enim eu turpis egestas. Dui faucibus in ornare quam viverra. Fermentum iaculis eu non diam phasellus vestibulum lorem. Odio ut enim blandit volutpat maecenas. Dolor sit amet consectetur adipiscing elit pellentesque habitant morbi tristique. Pellentesque diam volutpat commodo sed egestas egestas. Aliquam purus sit amet luctus venenatis lectus magna fringilla. Viverra mauris in aliquam sem fringilla ut morbi tincidunt. Elit duis tristique sollicitudin nibh sit. Fermentum dui faucibus in ornare quam viverra orci sagittis. Aliquet eget sit amet tellus cras adipiscing.",
"iconName": "ClipboardIcon",
"tintColor": "EC008C",
"size": 438855,
"permissions": [
{
"type": "background-audio",
"usageDescription": "Allows Clip to continuously monitor your clipboard in the background."
}
],
"screenshotNames": [
"Clip1",
"Clip2"
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -1,12 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "DeltaIcon.png"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Some files were not shown because too many files have changed in this diff Show More