Files
filza27.2/UpdateChecker.m
T

256 lines
10 KiB
Objective-C

#import "UpdateChecker.h"
#import <UIKit/UIKit.h>
static NSString *const kFSUpdateAPIURL =
@"https://api.github.com/repos/0xjohnnydev/FilzaSlop/releases/latest";
static NSString *const kFSVersionInfoKey = @"FilzaSlopVersion";
static NSString *const kFSLastCheckDateKey = @"FilzaSlopUpdateLastCheckDate";
static NSString *const kFSLastCheckedVersionKey =
@"FilzaSlopUpdateLastCheckedVersion";
static NSString *const kFSLastAlertedTagKey =
@"FilzaSlopUpdateLastAlertedTag";
static const NSTimeInterval kFSUpdateCheckInterval = 24.0 * 60.0 * 60.0;
static BOOL gFSUpdateCheckerStarted;
static BOOL gFSUpdateCheckInFlight;
static BOOL gFSUpdatePresentationScheduled;
static NSDictionary<NSString *, NSString *> *gFSPendingRelease;
static NSString *FSUpdateVersion(NSString *value)
{
if (![value isKindOfClass:NSString.class]) return nil;
NSString *version = [value stringByTrimmingCharactersInSet:
NSCharacterSet.whitespaceAndNewlineCharacterSet];
if ([version hasPrefix:@"v"] || [version hasPrefix:@"V"])
version = [version substringFromIndex:1];
return version.length > 0 ? version : nil;
}
static NSString *FSCurrentVersion(void)
{
return FSUpdateVersion([NSBundle.mainBundle objectForInfoDictionaryKey:
kFSVersionInfoKey]);
}
static BOOL FSUpdateVersionIsNewer(NSString *latest, NSString *current)
{
NSString *latestVersion = FSUpdateVersion(latest);
NSString *currentVersion = FSUpdateVersion(current);
if (!latestVersion || !currentVersion) return NO;
return [latestVersion compare:currentVersion options:NSNumericSearch] ==
NSOrderedDescending;
}
static UIViewController *FSTopViewController(UIViewController *controller)
{
UIViewController *current = controller;
while (current) {
UIViewController *next = nil;
if (current.presentedViewController &&
!current.presentedViewController.isBeingDismissed) {
next = current.presentedViewController;
} else if ([current isKindOfClass:UINavigationController.class]) {
next = ((UINavigationController *)current).visibleViewController;
} else if ([current isKindOfClass:UITabBarController.class]) {
next = ((UITabBarController *)current).selectedViewController;
}
if (!next || next == current) break;
current = next;
}
return current;
}
static UIViewController *FSUpdatePresenter(void)
{
UIApplication *application = UIApplication.sharedApplication;
UIWindow *selectedWindow = nil;
for (UIScene *scene in application.connectedScenes) {
if (![scene isKindOfClass:UIWindowScene.class] ||
scene.activationState != UISceneActivationStateForegroundActive)
continue;
for (UIWindow *window in ((UIWindowScene *)scene).windows) {
if (window.isHidden || window.alpha == 0.0 || !window.rootViewController)
continue;
if (window.isKeyWindow) return FSTopViewController(window.rootViewController);
if (!selectedWindow) selectedWindow = window;
}
}
return selectedWindow ? FSTopViewController(selectedWindow.rootViewController)
: nil;
}
static NSURL *FSReleaseURL(NSString *value)
{
NSURL *url = [NSURL URLWithString:value ?: @""];
NSString *host = url.host.lowercaseString;
NSString *path = url.path.lowercaseString;
if (![url.scheme.lowercaseString isEqualToString:@"https"] ||
![host isEqualToString:@"github.com"] ||
![path hasPrefix:@"/0xjohnnydev/filzaslop/releases/"])
return nil;
return url;
}
static void FSScheduleUpdatePresentation(NSUInteger attemptsRemaining);
static void FSPresentPendingUpdate(void)
{
NSDictionary<NSString *, NSString *> *release = gFSPendingRelease;
if (!release) return;
UIViewController *presenter = FSUpdatePresenter();
if (!presenter || !presenter.view.window || presenter.isBeingDismissed ||
[presenter isKindOfClass:UIAlertController.class])
return;
NSString *tag = release[@"tag"];
NSString *current = FSCurrentVersion();
NSURL *releaseURL = FSReleaseURL(release[@"url"]);
if (!tag.length || !current.length || !releaseURL) {
gFSPendingRelease = nil;
return;
}
NSString *message = [NSString stringWithFormat:
@"Version %@ is available. You have version %@.", tag, current];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"FilzaSlop Update Available"
message:message preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Later"
style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:@"View Release"
style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
[UIApplication.sharedApplication openURL:releaseURL options:@{}
completionHandler:nil];
}]];
gFSPendingRelease = nil;
[presenter presentViewController:alert animated:YES completion:^{
[NSUserDefaults.standardUserDefaults setObject:tag
forKey:kFSLastAlertedTagKey];
}];
}
static void FSScheduleUpdatePresentation(NSUInteger attemptsRemaining)
{
if (!gFSPendingRelease || gFSUpdatePresentationScheduled) return;
gFSUpdatePresentationScheduled = YES;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC),
dispatch_get_main_queue(), ^{
gFSUpdatePresentationScheduled = NO;
FSPresentPendingUpdate();
if (gFSPendingRelease && attemptsRemaining > 1)
FSScheduleUpdatePresentation(attemptsRemaining - 1);
});
}
static void FSHandleReleaseResponse(NSData *data, NSURLResponse *response,
NSError *error)
{
dispatch_async(dispatch_get_main_queue(), ^{
gFSUpdateCheckInFlight = NO;
NSInteger statusCode = [response isKindOfClass:NSHTTPURLResponse.class]
? ((NSHTTPURLResponse *)response).statusCode : 0;
if (error || statusCode != 200 || data.length == 0) {
NSLog(@"[UpdateChecker] request failed status=%ld error=%@",
(long)statusCode, error);
return;
}
NSError *jsonError = nil;
NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:data
options:0 error:&jsonError];
if (![payload isKindOfClass:NSDictionary.class]) {
NSLog(@"[UpdateChecker] invalid response error=%@", jsonError);
return;
}
NSString *tag = [payload[@"tag_name"] isKindOfClass:NSString.class]
? payload[@"tag_name"] : nil;
NSString *releaseURL = [payload[@"html_url"] isKindOfClass:NSString.class]
? payload[@"html_url"] : nil;
NSString *current = FSCurrentVersion();
if (tag == nil || releaseURL == nil) {
NSLog(@"[UpdateChecker] response omitted release metadata");
return;
}
if (!FSUpdateVersionIsNewer(tag, current)) {
NSLog(@"[UpdateChecker] current=%@ latest=%@", current, tag);
return;
}
NSString *lastAlerted = [NSUserDefaults.standardUserDefaults
stringForKey:kFSLastAlertedTagKey];
if ([lastAlerted isEqualToString:tag]) return;
if (!FSReleaseURL(releaseURL)) {
NSLog(@"[UpdateChecker] rejected release URL %@", releaseURL);
return;
}
gFSPendingRelease = @{ @"tag": tag, @"url": releaseURL };
FSScheduleUpdatePresentation(10);
});
}
static void FSCheckForUpdateIfNeeded(void)
{
NSCAssert(NSThread.isMainThread,
@"Update checks must start on the main thread");
if (gFSUpdateCheckInFlight) return;
NSString *current = FSCurrentVersion();
if (!current.length) {
NSLog(@"[UpdateChecker] %@ is missing; skipping update check",
kFSVersionInfoKey);
return;
}
NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults;
NSDate *lastCheck = [defaults objectForKey:kFSLastCheckDateKey];
if (![lastCheck isKindOfClass:NSDate.class]) lastCheck = nil;
NSString *lastVersion = [defaults stringForKey:kFSLastCheckedVersionKey];
NSTimeInterval elapsed = lastCheck
? [NSDate.date timeIntervalSinceDate:lastCheck] : kFSUpdateCheckInterval;
BOOL sameVersion = [lastVersion isEqualToString:current];
if (sameVersion && elapsed >= 0.0 && elapsed < kFSUpdateCheckInterval)
return;
[defaults setObject:NSDate.date forKey:kFSLastCheckDateKey];
[defaults setObject:current forKey:kFSLastCheckedVersionKey];
gFSUpdateCheckInFlight = YES;
NSMutableURLRequest *request = [NSMutableURLRequest
requestWithURL:[NSURL URLWithString:kFSUpdateAPIURL]
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:15.0];
[request setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"];
[request setValue:@"2022-11-28"
forHTTPHeaderField:@"X-GitHub-Api-Version"];
[request setValue:@"FilzaSlop-UpdateChecker" forHTTPHeaderField:@"User-Agent"];
NSURLSessionDataTask *task = [NSURLSession.sharedSession
dataTaskWithRequest:request
completionHandler:^(NSData *data, NSURLResponse *response,
NSError *error) {
FSHandleReleaseResponse(data, response, error);
}];
[task resume];
}
void FSUpdateCheckerStart(void)
{
dispatch_async(dispatch_get_main_queue(), ^{
if (gFSUpdateCheckerStarted) return;
gFSUpdateCheckerStarted = YES;
[NSNotificationCenter.defaultCenter
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil queue:NSOperationQueue.mainQueue
usingBlock:^(__unused NSNotification *notification) {
FSScheduleUpdatePresentation(10);
FSCheckForUpdateIfNeeded();
}];
if (UIApplication.sharedApplication.applicationState ==
UIApplicationStateActive)
FSCheckForUpdateIfNeeded();
});
}