Homebrew Hub integration in iOS

This commit is contained in:
Lior Halphon 2024-06-08 18:55:33 +03:00
parent 0bc0618a6a
commit 93cc19b3b9
12 changed files with 1160 additions and 2 deletions

41
iOS/GBHub.h Normal file
View File

@ -0,0 +1,41 @@
#import <Foundation/Foundation.h>
typedef enum {
GBHubStatusNotReady,
GBHubStatusInProgress,
GBHubStatusReady,
GBHubStatusError,
} GBHubStatus;
static inline NSString *GBSearchCanonicalString(NSString *string)
{
return [[string.lowercaseString stringByApplyingTransform:NSStringTransformStripDiacritics reverse:false] stringByApplyingTransform:NSStringTransformStripCombiningMarks reverse:false];
}
@interface GBHubGame : NSObject
@property (readonly) NSString *title;
@property (readonly) NSString *developer;
@property (readonly) NSString *type;
@property (readonly) NSString *license;
@property (readonly) NSDate *publicationDate;
@property (readonly) NSArray <NSString *> *tags;
@property (readonly) NSURL *repository;
@property (readonly) NSURL *website;
@property (readonly) NSArray <NSURL *> *screenshots;
@property (readonly) NSURL *file;
@property (readonly) NSString *slug;
@property (readonly) NSString *entryDescription;
@property (readonly) NSString *keywords;
@end
extern NSString *const GBHubStatusChangedNotificationName;
@interface GBHub : NSObject
+ (instancetype)sharedHub;
- (void)refresh;
- (unsigned)countForTag:(NSString *)tag;
@property (readonly) GBHubStatus status;
@property (readonly) NSDictionary<NSString *, GBHubGame *> *allGames;
@property (readonly) NSArray<NSString *> *sortedTags;
@property (readonly) NSArray<GBHubGame *> *showcaseGames;
@end

339
iOS/GBHub.m Normal file
View File

@ -0,0 +1,339 @@
#import "GBHub.h"
#pragma clang diagnostic ignored "-Warc-retain-cycles"
NSString *const GBHubStatusChangedNotificationName = @"GBHubStatusChangedNotification";
static NSURL *StringToWebURL(NSString *string)
{
if (![string isKindOfClass:[NSString class]]) return nil;
NSURL *url = [NSURL URLWithString:string];
if (![url.scheme isEqual:@"http"] && [url.scheme isEqual:@"https"]) {
return nil;
}
return url;
}
@implementation GBHubGame
- (instancetype)initWithJSON:(NSDictionary *)json
{
self = [super init];
// Skip NSFW titles
if ([json[@"nsfw"] boolValue]) return nil;
if (json[@"tags"] && ![json[@"tags"] isKindOfClass:[NSArray class]]) return nil;
_tags = [NSMutableArray array];
for (__strong NSString *tag in json[@"tags"]) {
if (![tag isKindOfClass:[NSString class]]) {
return nil;
}
if ([tag isEqual:@"hw:gbprinter"]) {
continue;
}
if ([tag hasPrefix:@"event:"]) {
tag = [tag substringFromIndex:strlen("event:")];
}
if ([tag hasPrefix:@"gb-showdown-"]) {
tag = [NSString stringWithFormat:@"Game Boy Showdown %@", [tag substringFromIndex:strlen("gb-showdown-")]];
}
if ([tag hasPrefix:@"gbcompo"]) {
tag = [NSString stringWithFormat:@"GBCompo%@", [[tag substringFromIndex:strlen("gbcompo")].capitalizedString stringByReplacingOccurrencesOfString:@"-" withString:@" "]];
}
if ([tag isEqual:tag.lowercaseString]) {
tag = [tag stringByReplacingOccurrencesOfString:@"-" withString:@" "].capitalizedString;
}
[(NSMutableArray *)_tags addObject:tag];
}
NSMutableSet *licenses = [NSMutableSet set];
if (json[@"license"]) {
[licenses addObject:json[@"license"]];
}
if (json[@"gameLicense"]) {
[licenses addObject:json[@"gameLicense"]];
}
if (json[@"assetsLicense"]) {
[licenses addObject:json[@"assetsLicense"]];
}
if (licenses.count == 1) {
_license = licenses.anyObject;
if (![_license isKindOfClass:[NSString class]]) {
return nil;
}
if (!_license.length) _license = nil;
}
else if (licenses.count > 1) {
if (json[@"license"]) {
return nil;
}
_license = [NSString stringWithFormat:@"%@ (Assets: %@)", json[@"gameLicense"], json[@"assetsLicense"]];
}
if (_license && ![_tags containsObject:@"Open Source"]) {
// License is guaranteed to be Open Source by spec
[(NSMutableArray *)_tags addObject:@"Open Source"];
}
_title = json[@"title"];
if (![_title isKindOfClass:[NSString class]]) {
return nil;
}
_entryDescription = json[@"description"];
if (_entryDescription && ![_entryDescription isKindOfClass:[NSString class]]) {
return nil;
}
_developer = json[@"developer"];
if (![_developer isKindOfClass:[NSString class]]) {
if ([_developer isKindOfClass:[NSArray class]] && ((NSArray *)_developer).count) {
if ([((NSArray *)_developer)[0] isKindOfClass:[NSString class]]) {
_developer = [(NSArray *)_developer componentsJoinedByString:@", "];
}
else if ([((NSArray *)_developer)[0] isKindOfClass:[NSDictionary class]]) {
NSMutableArray *developers = [NSMutableArray array];
for (NSDictionary *developer in (NSArray *)_developer) {
if (![developer isKindOfClass:[NSDictionary class]]) return nil;
NSString *name = developer[@"name"];
if (!name) return nil;
[developers addObject:name];
}
_developer = [developers componentsJoinedByString:@", "];
}
else {
return nil;
}
}
else if ([_developer isKindOfClass:[NSDictionary class]]) {
_developer = ((NSDictionary *)_developer)[@"name"];
}
else {
return nil;
}
}
_slug = json[@"slug"];
if (![_slug isKindOfClass:[NSString class]]) {
return nil;
}
_type = json[@"typetag"];
if (![_type isKindOfClass:[NSString class]]) {
return nil;
}
NSString *dateString = json[@"date"];
if ([dateString isKindOfClass:[NSString class]]) {
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"yyyy-MM-dd"];
_publicationDate = [dateFormatter dateFromString:dateString];
}
_repository = StringToWebURL(json[@"repository"]);
_website = StringToWebURL(json[@"website"]);
NSURL *base = [NSURL URLWithString:[NSString stringWithFormat:@"https://hh3.gbdev.io/static/database-gb/entries/%@", _slug]];
NSMutableArray *screenshots = [NSMutableArray array];
for (NSString *screenshot in json[@"screenshots"]) {
[screenshots addObject:[base URLByAppendingPathComponent:screenshot]];
}
_screenshots = screenshots;
for (NSDictionary *file in json[@"files"]) {
NSString *extension = [file[@"filename"] pathExtension].lowercaseString;
if (![extension isEqual:@"gb"] && ![extension isEqual:@"gbc"]) {
// Not a DMG/CGB game
continue;
}
if ([file[@"default"] boolValue] || !_file) {
_file = [base URLByAppendingPathComponent:file[@"filename"]];
}
}
if (!_file) {
return nil;
}
_keywords = [NSString stringWithFormat:@"%@ %@ %@ %@",
GBSearchCanonicalString(_title),
GBSearchCanonicalString(_developer),
GBSearchCanonicalString(_entryDescription) ?: @"",
GBSearchCanonicalString([_tags componentsJoinedByString:@" "])
];
return self;
}
- (NSString *)description
{
return [NSString stringWithFormat:@"<%@ %p: %@>", self.class, self, self.title];
}
@end
@implementation GBHub
{
NSMutableDictionary<NSString *, GBHubGame *> *_allGames;
NSMutableDictionary<NSString *, NSNumber *> *_tags;
NSMutableArray<GBHubGame *> *_showcaseGames;
NSSet<NSString *> *_showcaseExtras;
}
+ (instancetype)sharedHub
{
static GBHub *hub = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
hub = [[self alloc] init];
});
return hub;
}
- (void)setStatus:(GBHubStatus)status
{
if (_status != status) {
_status = status;
if ([NSThread isMainThread]) {
[[NSNotificationCenter defaultCenter] postNotificationName:GBHubStatusChangedNotificationName
object:self];
}
else {
dispatch_sync(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:GBHubStatusChangedNotificationName
object:self];
});
}
}
}
- (void)handleAPIData:(NSData *)data forBaseURL:(NSString *)base completion:(void(^)(GBHubStatus))completion
{
@try {
if (!data) {
completion(GBHubStatusError);
return;
}
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (!json) {
completion(GBHubStatusError);
return;
}
if (!json[@"page_current"] || !json[@"page_total"]) {
completion(GBHubStatusError);
return;
}
for (NSDictionary *entry in json[@"entries"]) {
@autoreleasepool {
GBHubGame *game = [[GBHubGame alloc] initWithJSON:entry];
if (game && !_allGames[game.slug]) {
_allGames[game.slug] = game;
bool showcase = [_showcaseExtras containsObject:game.slug];
if (!showcase) {
for (NSString *tag in game.tags) {
_tags[tag] = @(_tags[tag].unsignedIntValue + 1);
if ([tag containsString:@"Shortlist"]) {
showcase = true;
break;
}
}
}
if (showcase) {
[_showcaseGames addObject:game];
}
}
}
}
if ([json[@"page_current"] unsignedIntValue] == [json[@"page_total"] unsignedIntValue]) {
completion(GBHubStatusReady);
return;
}
[[[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"%@&page=%u",
base,
[json[@"page_current"] unsignedIntValue] + 1]]
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
[self handleAPIData:data forBaseURL:base completion:completion];
}] resume];
}
@catch (NSException *exception) {
self.status = GBHubStatusError;
}
}
- (void)addGamesForURL:(NSString *)url completion:(void(^)(GBHubStatus))completion
{
[[[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:url]
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
completion(GBHubStatusError);
return;
}
[self handleAPIData:data forBaseURL:url completion:completion];
}] resume];
}
- (unsigned int)countForTag:(NSString *)tag
{
return _tags[tag].unsignedIntValue;
}
- (void)refresh
{
if (_status == GBHubStatusInProgress) {
return;
}
self.status = GBHubStatusInProgress;
_allGames = [NSMutableDictionary dictionary];
_tags = [NSMutableDictionary dictionary];
_showcaseGames = [NSMutableArray array];
[[[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:@"https://sameboy.github.io/ios-showcase"]
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (data) {
_showcaseExtras = [NSSet setWithArray:[[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] componentsSeparatedByString:@"\n"]];
}
[self addGamesForURL:@"https://hh3.gbdev.io/api/search?tags=Open+Source&results=1000"
completion:^(GBHubStatus ret) {
if (ret != GBHubStatusReady) {
self.status = ret;
return;
}
[self addGamesForURL:@"https://hh3.gbdev.io/api/search?thirdparty=sameboy&results=1000"
completion:^(GBHubStatus ret) {
if (ret == GBHubStatusReady) {
_sortedTags = [_tags.allKeys sortedArrayUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) {
return [obj1 compare:obj2];
}];
}
unsigned day = time(NULL) / 60 / 60 / 24;
if (_showcaseGames.count > 5) {
typeof(_showcaseGames) temp = [NSMutableArray array];
for (unsigned i = 5; i--;) {
unsigned index = day % _showcaseGames.count;
GBHubGame *game = _showcaseGames[index];
[_showcaseGames removeObjectAtIndex:index];
[temp addObject:game];
}
_showcaseGames = temp;
}
self.status = ret;
}];
}];
}] resume];
}
@end

8
iOS/GBHubCell.h Normal file
View File

@ -0,0 +1,8 @@
#import <UIKit/UIKit.h>
#import "GBHub.h"
@interface GBHubCell : UITableViewCell
@property GBHubGame *game;
@end

4
iOS/GBHubCell.m Normal file
View File

@ -0,0 +1,4 @@
#import "GBHubCell.h"
@implementation GBHubCell
@end

View File

@ -0,0 +1,7 @@
#import <UIKit/UIKit.h>
#import "GBHub.h"
@interface GBHubGameViewController : UIViewController
- (instancetype)initWithGame:(GBHubGame *)game;
@end

View File

@ -0,0 +1,315 @@
#import "GBHubGameViewController.h"
#import "GBROMManager.h"
#import "UILabel+TapLocation.h"
@implementation NSMutableAttributedString (append)
- (void)appendWithAttributes:(NSDictionary *)attributes format:(NSString *)format, ...
{
va_list args;
va_start(args, format);
NSString *string = [[NSString alloc] initWithFormat:format arguments:args];
va_end(args);
[self appendAttributedString:[[NSAttributedString alloc] initWithString:string attributes:attributes]];
}
@end
@implementation GBHubGameViewController
{
GBHubGame *_game;
UIScrollView *_scrollView;
UIScrollView *_screenshotsScrollView;
UILabel *_titleLabel;
UILabel *_descriptionLabel;
}
- (instancetype)initWithGame:(GBHubGame *)game
{
self = [super init];
_game = game;
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
UIColor *labelColor, *linkColor, *secondaryLabelColor;
if (@available(iOS 13.0, *)) {
self.view.backgroundColor = [UIColor systemBackgroundColor];
labelColor = UIColor.labelColor;
linkColor = UIColor.linkColor;
secondaryLabelColor = UIColor.secondaryLabelColor;
}
else {
self.view.backgroundColor = [UIColor whiteColor];
labelColor = UIColor.blackColor;
linkColor = UIColor.blueColor;
secondaryLabelColor = [UIColor colorWithWhite:0.55 alpha:1.0];
}
_scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
_scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
_scrollView.scrollEnabled = true;
_scrollView.pagingEnabled = false;
_scrollView.showsVerticalScrollIndicator = true;
_scrollView.showsHorizontalScrollIndicator = false;
[self.view addSubview:_scrollView];
_scrollView.contentSize = CGSizeMake(self.view.bounds.size.width, self.view.bounds.size.height * 2);
_titleLabel = [[UILabel alloc] initWithFrame:(CGRectMake(0, 8,
self.view.bounds.size.width - 16,
56))];
NSMutableParagraphStyle *style = [NSParagraphStyle defaultParagraphStyle].mutableCopy;
style.paragraphSpacing = 4;
NSMutableAttributedString *titleText = [[NSMutableAttributedString alloc] init];
[titleText appendWithAttributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:34 weight:UIFontWeightBold],
NSForegroundColorAttributeName: labelColor,
NSParagraphStyleAttributeName: style,
} format:@"%@", _game.title];
[titleText appendWithAttributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:20],
NSForegroundColorAttributeName: secondaryLabelColor,
NSParagraphStyleAttributeName: style,
} format:@"\n by %@", _game.developer];
_titleLabel.attributedText = titleText;
_titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
_titleLabel.numberOfLines = 0;
[_scrollView addSubview:_titleLabel];
NSMutableAttributedString *text = [[NSMutableAttributedString alloc] init];
NSDictionary *labelAttributes = @{
NSFontAttributeName: [UIFont boldSystemFontOfSize:UIFont.labelFontSize],
NSForegroundColorAttributeName: labelColor,
};
NSDictionary *valueAttributes = @{
NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize],
NSForegroundColorAttributeName: labelColor,
};
if (_game.entryDescription) {
[text appendWithAttributes:valueAttributes format:@"%@\n\n", _game.entryDescription];
}
if (_game.publicationDate) {
[text appendWithAttributes:labelAttributes format:@"Published: "];
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
formatter.dateStyle = NSDateFormatterMediumStyle;
formatter.timeStyle = NSDateFormatterNoStyle;
formatter.locale = [NSLocale currentLocale];
[text appendWithAttributes:valueAttributes format:@"%@\n", [formatter stringFromDate:_game.publicationDate]];
}
if (_game.website) {
[text appendWithAttributes:labelAttributes format:@"Website: "];
[text appendWithAttributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize],
@"GBLinkAttribute": _game.website,
NSForegroundColorAttributeName: linkColor,
} format:@"%@\n", _game.website];
}
if (_game.repository) {
[text appendWithAttributes:labelAttributes format:@"Repository: "];
[text appendWithAttributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize],
@"GBLinkAttribute": _game.repository,
NSForegroundColorAttributeName: linkColor,
} format:@"%@\n", _game.repository];
}
if (_game.license) {
[text appendWithAttributes:labelAttributes format:@"License: "];
[text appendWithAttributes:valueAttributes format:@"%@\n", _game.license];
}
if (_game.tags.count) {
[text appendWithAttributes:labelAttributes format:@"Categories: "];
bool first = true;
for (NSString *tag in _game.tags) {
if (!first) {
[text appendWithAttributes:valueAttributes format:@", ", _game.license];
}
first = false;
[text appendWithAttributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize],
@"GBHubTag": tag,
NSForegroundColorAttributeName: linkColor,
} format:@"%@", tag];
}
[text appendWithAttributes:valueAttributes format:@"\n"];
}
_descriptionLabel = [[UILabel alloc] init];
_descriptionLabel.numberOfLines = 0;
_descriptionLabel.lineBreakMode = NSLineBreakByWordWrapping;
if (@available(iOS 14.0, *)) {
_descriptionLabel.lineBreakStrategy = NSLineBreakStrategyNone;
}
_descriptionLabel.attributedText = text;
[_scrollView addSubview:_descriptionLabel];
unsigned screenshotWidth = (unsigned)(MIN(self.view.bounds.size.width, self.view.bounds.size.height) - 16) / 160 * 160;
unsigned screenshotHeight = screenshotWidth / 160 * 144;
if (_game.screenshots.count) {
_screenshotsScrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0,
self.view.bounds.size.width,
screenshotHeight + 8)];
_screenshotsScrollView.scrollEnabled = true;
_screenshotsScrollView.pagingEnabled = false;
_screenshotsScrollView.showsVerticalScrollIndicator = false;
_screenshotsScrollView.showsHorizontalScrollIndicator = true;
unsigned x = 0;
for (NSURL *url in _game.screenshots) {
x += 8;
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(x, 0,
screenshotWidth,
screenshotHeight)];
[imageView.layer setMinificationFilter:kCAFilterLinear];
[imageView.layer setMagnificationFilter:kCAFilterNearest];
imageView.layer.cornerRadius = 4;
imageView.layer.borderWidth = 1;
imageView.layer.masksToBounds = true;
if (@available(iOS 13.0, *)) {
imageView.layer.borderColor = [UIColor tertiaryLabelColor].CGColor;
}
else {
imageView.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.5].CGColor;
}
[_screenshotsScrollView addSubview:imageView];
[[[NSURLSession sharedSession] downloadTaskWithURL:url completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
if (location) {
UIImage *image = [UIImage imageWithContentsOfFile:location.path];
dispatch_async(dispatch_get_main_queue(), ^{
CGRect frame = imageView.frame;
imageView.image = image;
imageView.frame = frame;
imageView.contentMode = UIViewContentModeScaleAspectFit;
});
}
}] resume];
x += screenshotWidth + 8;
}
_screenshotsScrollView.contentSize = CGSizeMake(x, screenshotHeight);
_screenshotsScrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin;
[_scrollView addSubview:_screenshotsScrollView];
}
[self viewDidLayoutSubviews];
_descriptionLabel.userInteractionEnabled = true;
[_descriptionLabel addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(tappedLabel:)]];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Download"
style:UIBarButtonItemStylePlain
target:self
action:@selector(rightButtonPressed)];
if ([GBROMManager.sharedManager romFileForROM:_game.title]) {
self.navigationItem.rightBarButtonItem.title = @"Open";
}
}
- (void)rightButtonPressed
{
if ([GBROMManager.sharedManager romFileForROM:_game.title]) {
[GBROMManager sharedManager].currentROM = _game.title;
[self.navigationController dismissViewControllerAnimated:true completion:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:@"GBROMChanged" object:nil];
}
else {
UIActivityIndicatorViewStyle style = UIActivityIndicatorViewStyleWhite;
if (@available(iOS 13.0, *)) {
style = UIActivityIndicatorViewStyleMedium;
}
UIActivityIndicatorView *view = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:style];
CGRect frame = view.frame;
frame.size.width += 16;
view.frame = frame;
[view startAnimating];
self.navigationItem.rightBarButtonItem.customView = view;
[[[NSURLSession sharedSession] downloadTaskWithURL:_game.file completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
if (!location) {
dispatch_async(dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Could not download ROM"
message:@"Could not download this ROM from Homebrew Hub. Please try again later."
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Close"
style:UIAlertActionStyleCancel
handler:nil]];
[self presentViewController:alert animated:true completion:nil];
self.navigationItem.rightBarButtonItem.customView = nil;
});
return;
}
NSString *newTempName = [[location.path stringByDeletingLastPathComponent] stringByAppendingPathComponent:_game.file.lastPathComponent];
[[NSFileManager defaultManager] moveItemAtPath:location.path toPath:newTempName error:nil];
[[GBROMManager sharedManager] importROM:newTempName withName:_game.title keepOriginal:false];
dispatch_async(dispatch_get_main_queue(), ^{
self.navigationItem.rightBarButtonItem.title = @"Open";
self.navigationItem.rightBarButtonItem.customView = nil;
});
}] resume];
}
}
- (void)tappedLabel:(UITapGestureRecognizer *)tap
{
unsigned characterIndex = [(UILabel *)tap.view characterAtTap:tap];
NSURL *url = [((UILabel *)tap.view).attributedText attribute:@"GBLinkAttribute" atIndex:characterIndex effectiveRange:NULL];
if (url) {
[[UIApplication sharedApplication] openURL:url options:nil completionHandler:nil];
return;
}
NSString *tag = [((UILabel *)tap.view).attributedText attribute:@"GBHubTag" atIndex:characterIndex effectiveRange:NULL];
if (tag) {
UINavigationItem *parent = self.navigationController.navigationBar.items[self.navigationController.navigationBar.items.count - 2];
[self.navigationController popViewControllerAnimated:true];
if (@available(iOS 13.0, *)) {
parent.searchController.searchBar.searchTextField.text = @"";
parent.searchController.searchBar.searchTextField.tokens = nil;
UISearchToken *token = [UISearchToken tokenWithIcon:nil text:tag];
token.representedObject = tag;
[parent.searchController.searchBar.searchTextField insertToken:token atIndex:0];
}
else {
parent.searchController.searchBar.text = tag;
}
parent.searchController.active = true;
return;
}
}
- (void)viewDidLayoutSubviews
{
unsigned y = 12;
CGSize size = [_titleLabel sizeThatFits:(CGSize){_scrollView.bounds.size.width - 32, INFINITY}];;
_titleLabel.frame = (CGRect){{16, y}, {_scrollView.bounds.size.width - 32, size.height}};
y += size.height + 24;
if (_screenshotsScrollView) {
_screenshotsScrollView.frame = CGRectMake(0, y, _scrollView.bounds.size.width, _screenshotsScrollView.frame.size.height);
y += _screenshotsScrollView.frame.size.height + 8;
if (_game.screenshots.count == 1) {
CGRect frame = _screenshotsScrollView.frame;
frame.origin.x = (_scrollView.bounds.size.width - _screenshotsScrollView.contentSize.width) / 2;
_screenshotsScrollView.frame = frame;
}
}
size = [_descriptionLabel sizeThatFits:(CGSize){_scrollView.bounds.size.width - 32, INFINITY}];;
_descriptionLabel.frame = (CGRect){{16, y}, {_scrollView.bounds.size.width - 32, size.height}};
y += size.height;
_scrollView.contentSize = CGSizeMake(_scrollView.bounds.size.width, y);
}
@end

View File

@ -0,0 +1,6 @@
#import <UIKit/UIKit.h>
@interface GBHubViewController : UITableViewController
@end

393
iOS/GBHubViewController.m Normal file
View File

@ -0,0 +1,393 @@
#import "GBHubViewController.h"
#import "GBHub.h"
#import "GBHubGameViewController.h"
#import "GBHubCell.h"
#import "UILabel+TapLocation.h"
@interface GBHubViewController() <UISearchResultsUpdating>
@end
@implementation GBHubViewController
{
UISearchController *_searchController;
NSMutableDictionary<NSURL *, UIImage *> *_imageCache;
NSArray<GBHubGame *> *_results;
NSString *_resultsTitle;
bool _showingAllGames;
}
- (instancetype)init
{
self = [self initWithStyle:UITableViewStyleGrouped];
[[NSNotificationCenter defaultCenter] addObserver:self.tableView
selector:@selector(reloadData)
name:GBHubStatusChangedNotificationName
object:nil];
_imageCache = [NSMutableDictionary dictionary];
self.tableView.rowHeight = UITableViewAutomaticDimension;
[GBHub.sharedHub refresh];
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.navigationItem.searchController =
_searchController = [[UISearchController alloc] initWithSearchResultsController:nil];
_searchController.searchResultsUpdater = self;
self.tableView.scrollsToTop = true;
self.navigationItem.hidesSearchBarWhenScrolling = false;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UIColor *labelColor;
UIColor *secondaryLabelColor;
if (@available(iOS 13.0, *)) {
labelColor = UIColor.labelColor;
secondaryLabelColor = UIColor.secondaryLabelColor;
}
else {
labelColor = UIColor.blackColor;
secondaryLabelColor = [UIColor colorWithWhite:0.55 alpha:1.0];
}
switch (GBHub.sharedHub.status) {
case GBHubStatusNotReady: return nil;
case GBHubStatusInProgress: {
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:0];
UIActivityIndicatorViewStyle style = UIActivityIndicatorViewStyleWhite;
if (@available(iOS 13.0, *)) {
style = UIActivityIndicatorViewStyleMedium;
}
UIActivityIndicatorView *spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:style];
cell.bounds = spinner.bounds;
[cell addSubview:spinner];
[spinner startAnimating];
spinner.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
return cell;
}
case GBHubStatusReady: {
if (indexPath.section == 0) {
GBHubCell *cell = [[GBHubCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:0];
cell.game = _results? _results[indexPath.item] : GBHub.sharedHub.showcaseGames[indexPath.item];
NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:cell.game.title
attributes:@{
NSFontAttributeName: [UIFont boldSystemFontOfSize:UIFont.labelFontSize],
NSForegroundColorAttributeName: labelColor
}];
[text appendAttributedString:[[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@" by %@\n",
cell.game.developer]
attributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize],
NSForegroundColorAttributeName: labelColor
}]];
[text appendAttributedString:[[NSAttributedString alloc] initWithString:cell.game.entryDescription ?: [cell.game.tags componentsJoinedByString:@", "]
attributes:@{
NSFontAttributeName: [UIFont systemFontOfSize:UIFont.smallSystemFontSize],
NSForegroundColorAttributeName: secondaryLabelColor
}]];
cell.textLabel.attributedText = text;
cell.textLabel.numberOfLines = 2;
cell.textLabel.lineBreakMode = NSLineBreakByTruncatingTail;
static UIImage *emptyImage = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
UIGraphicsBeginImageContextWithOptions((CGSize){60, 60}, false, tableView.window.screen.scale);
UIBezierPath *mask = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 3, 60, 54) cornerRadius:4];
[mask addClip];
[[UIColor whiteColor] set];
[mask fill];
if (@available(iOS 13.0, *)) {
[[UIColor tertiaryLabelColor] set];
}
else {
[[UIColor colorWithWhite:0 alpha:0.5] set];
}
[mask stroke];
emptyImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
});
cell.imageView.image = emptyImage;
return cell;
}
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:0];
cell.selectionStyle = UITableViewCellSelectionStyleBlue;
NSString *tag = GBHub.sharedHub.sortedTags[indexPath.item];
cell.textLabel.text = tag;
unsigned count = [GBHub.sharedHub countForTag:tag];
if (count == 1) {
cell.detailTextLabel.text = @"1 Game";
}
else {
cell.detailTextLabel.text = [NSString stringWithFormat:@"%u Games", count];
}
cell.textLabel.numberOfLines = 2;
cell.textLabel.lineBreakMode = NSLineBreakByWordWrapping;
return cell;
}
case GBHubStatusError: {
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:0];
cell.textLabel.text = @"Could not connect to Homebrew Hub";
return cell;
}
}
return nil;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
switch (GBHub.sharedHub.status) {
case GBHubStatusNotReady: return 0;
case GBHubStatusInProgress: return 1;
case GBHubStatusReady: {
if (_results) return _results.count;
if (section == 0) return GBHub.sharedHub.showcaseGames.count;
return GBHub.sharedHub.sortedTags.count;
}
case GBHubStatusError: return 1;
}
return 0;
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
switch (GBHub.sharedHub.status) {
case GBHubStatusNotReady: return 0;
case GBHubStatusInProgress: return 1;
case GBHubStatusReady: return _results? 1 : 2;
case GBHubStatusError: return 1;
}
return 0;
}
- (NSString *)title
{
return @"Homebrew Hub";
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
{
if (GBHub.sharedHub.status != GBHubStatusReady) return nil;
if (section == 0) return _results? _resultsTitle : @"Homebrew Showcase";
return @"Categories";
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
if (GBHub.sharedHub.status == GBHubStatusReady && indexPath.section == 0) {
return 60;
}
return 45;
}
- (void)tableView:(UITableView *)tableView willDisplayCell:(GBHubCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath
{
if (![cell isKindOfClass:[GBHubCell class]]) return;
if (!cell.game.screenshots.count) return;
NSURL *url = cell.game.screenshots[0];
UIImage *image = _imageCache[url];
if ([image isKindOfClass:[UIImage class]]) {
cell.imageView.image = image;
return;
}
if (!image) {
_imageCache[url] = (id)[NSNull null];
[[[NSURLSession sharedSession] downloadTaskWithURL:url
completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
if (!location) return;
dispatch_sync(dispatch_get_main_queue(), ^{
UIGraphicsBeginImageContextWithOptions((CGSize){60, 60}, false, tableView.window.screen.scale);
UIBezierPath *mask = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 3, 60, 54) cornerRadius:4];
[mask addClip];
UIImage *image = [UIImage imageWithContentsOfFile:location.path];
[image drawInRect:mask.bounds];
if (@available(iOS 13.0, *)) {
[[UIColor tertiaryLabelColor] set];
}
else {
[[UIColor colorWithWhite:0 alpha:0.5] set];
}
[mask stroke];
_imageCache[url] = cell.imageView.image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
});
}] resume];
}
}
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController
{
static unsigned cookie = 0;
NSArray<NSString *> *keywords = [GBSearchCanonicalString([searchController.searchBar.text
stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet])
componentsSeparatedByString:@" "];
if (keywords.count == 1 && keywords[0].length == 0) {
keywords = @[];
}
NSArray *tokens = nil;
if (@available(iOS 13.0, *)) {
tokens = searchController.searchBar.searchTextField.tokens;
}
if (!searchController.isActive && tokens.count == 0 && !keywords.count) {
cookie++;
_results = nil;
_showingAllGames = false;
[self.tableView reloadData];
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathWithIndexes:(NSUInteger[]){0,0} length:2]
atScrollPosition:UITableViewScrollPositionTop
animated:false];
return;
}
if (tokens.count || keywords.count) {
_showingAllGames = false;
cookie++;
unsigned myCookie = cookie;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSMutableArray<GBHubGame *> *results = [NSMutableArray array];
for (GBHubGame *game in GBHub.sharedHub.allGames.allValues) {
bool matches = true;
if (@available(iOS 13.0, *)) {
for (UISearchToken *token in tokens) {
if (![game.tags containsObject:token.representedObject]) {
matches = false;
break;
}
}
if (!matches) continue;
}
for (NSString *keyword in keywords) {
if (keyword.length == 0) continue;
if (![game.keywords containsString:keyword]) {
matches = false;
break;
}
}
if (matches) {
[results addObject:game];
}
}
dispatch_async(dispatch_get_main_queue(), ^{
if (myCookie != cookie) return;
if (tokens.count) {
if (searchController.searchBar.text.length) {
_resultsTitle = [NSString stringWithFormat:@"Showing %@ games matching “%@”", [tokens[0] representedObject], searchController.searchBar.text];
}
else {
_resultsTitle = [NSString stringWithFormat:@"Showing %@ games", [tokens[0] representedObject]];
}
}
else {
_resultsTitle = [NSString stringWithFormat:@"Showing results for “%@”", searchController.searchBar.text];
}
_results = results;
_results = [results sortedArrayUsingComparator:^NSComparisonResult(GBHubGame *obj1, GBHubGame *obj2) {
return [obj1.title.lowercaseString compare:obj2.title.lowercaseString];
}];
[self.tableView reloadData];
if (_results.count) {
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathWithIndexes:(NSUInteger[]){0,0} length:2]
atScrollPosition:UITableViewScrollPositionTop
animated:false];
}
});
});
}
else {
if (_showingAllGames) return;
cookie++;
_showingAllGames = true;
_resultsTitle = @"Showing all games";
_results = [GBHub.sharedHub.allGames.allValues sortedArrayUsingComparator:^NSComparisonResult(GBHubGame *obj1, GBHubGame *obj2) {
return [obj1.title.lowercaseString compare:obj2.title.lowercaseString];
}];
[self.tableView reloadData];
if (_results.count) {
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathWithIndexes:(NSUInteger[]){0,0} length:2]
atScrollPosition:UITableViewScrollPositionTop
animated:false];
}
return;
}
}
- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
if (GBHub.sharedHub.status == GBHubStatusReady) return indexPath;
return nil;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
if (GBHub.sharedHub.status != GBHubStatusReady) return;
if (indexPath.section == 1) {
NSString *tag = GBHub.sharedHub.sortedTags[indexPath.item];
if (@available(iOS 13.0, *)) {
UISearchToken *token = [UISearchToken tokenWithIcon:nil
text:tag];
token.representedObject = tag;
[_searchController.searchBar.searchTextField insertToken:token
atIndex:0];
}
else {
_searchController.searchBar.text = tag;
}
[_searchController setActive:true];
return;
}
GBHubCell *cell = [tableView cellForRowAtIndexPath:indexPath];
if ([cell isKindOfClass:[GBHubCell class]]) {
GBHubGameViewController *controller = [[GBHubGameViewController alloc] initWithGame:cell.game];
[self.navigationController pushViewController:controller animated:true];
}
}
- (void)tableView:(UITableView *)tableView willDisplayFooterView:(UIView *)view forSection:(NSInteger)section
{
UIColor *linkColor;
if (@available(iOS 13.0, *)) {
linkColor = UIColor.linkColor;
}
else {
linkColor = UIColor.blueColor;
}
if (section != [self numberOfSectionsInTableView:nil] - 1) return;
UITableViewHeaderFooterView *footer = (UITableViewHeaderFooterView *)view;
NSMutableAttributedString *string = footer.textLabel.attributedText.mutableCopy;
[string addAttributes:@{
@"GBLinkAttribute": [NSURL URLWithString:@"https://hh.gbdev.io"],
NSForegroundColorAttributeName: linkColor,
} range:[string.string rangeOfString:@"Homebrew Hub"]];
footer.textLabel.attributedText = string;
footer.textLabel.userInteractionEnabled = true;
[footer.textLabel addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(tappedFooterLabel:)]];
}
- (void)tappedFooterLabel:(UITapGestureRecognizer *)tap
{
unsigned characterIndex = [(UILabel *)tap.view characterAtTap:tap];
NSURL *url = [((UILabel *)tap.view).attributedText attribute:@"GBLinkAttribute" atIndex:characterIndex effectiveRange:NULL];
if (url) {
[[UIApplication sharedApplication] openURL:url options:nil completionHandler:nil];
}
}
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
{
if (section != [self numberOfSectionsInTableView:tableView] - 1) return nil;
return @"Powered by Homebrew Hub";
}
@end

View File

@ -1,6 +1,7 @@
#import "GBLoadROMTableViewController.h"
#import "GBROMManager.h"
#import "GBViewController.h"
#import "GBHubViewController.h"
#import <CoreServices/CoreServices.h>
#import <objc/runtime.h>
@ -32,7 +33,7 @@
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
if (section == 1) return 2;
if (section == 1) return 3;
return [GBROMManager sharedManager].allROMs.count;
}
@ -42,7 +43,8 @@
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
switch (indexPath.item) {
case 0: cell.textLabel.text = @"Import ROM files"; break;
case 1: cell.textLabel.text = @"Show Library in Files"; break;
case 1: cell.textLabel.text = @"Browse Homebrew Hub"; break;
case 2: cell.textLabel.text = @"Show Library in Files"; break;
}
return cell;
}
@ -139,6 +141,11 @@
return;
}
case 1: {
[self.navigationController pushViewController:[[GBHubViewController alloc] init]
animated:true];
return;
}
case 2: {
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:[NSString stringWithFormat:@"shareddocuments://%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject]]
options:nil
completionHandler:nil];
@ -323,4 +330,9 @@ contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
}
}
- (void)viewWillAppear:(BOOL)animated
{
[self.tableView reloadData];
}
@end

View File

@ -16,6 +16,7 @@
- (NSString *)autosaveStateFileForROM:(NSString *)rom;
- (NSString *)stateFile:(unsigned)index forROM:(NSString *)rom;
- (NSString *)importROM:(NSString *)romFile keepOriginal:(bool)keep;
- (NSString *)importROM:(NSString *)romFile withName:(NSString *)friendlyName keepOriginal:(bool)keep;
- (NSString *)renameROM:(NSString *)rom toName:(NSString *)newName;
- (NSString *)duplicateROM:(NSString *)rom;
- (void)deleteROM:(NSString *)rom;

View File

@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>
@interface UILabel (TapLocation)
- (unsigned)characterAtTap:(UITapGestureRecognizer *)tap;
@end

27
iOS/UILabel+TapLocation.m Normal file
View File

@ -0,0 +1,27 @@
#import "UILabel+TapLocation.h"
@implementation UILabel (TapLocation)
- (unsigned)characterAtTap:(UITapGestureRecognizer *)tap
{
CGPoint tapLocation = [tap locationInView:self];
NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:self.attributedText];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[textStorage addLayoutManager:layoutManager];
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(self.frame.size.width,
self.frame.size.height + 256)];
textContainer.lineFragmentPadding = 0;
textContainer.maximumNumberOfLines = 256;
textContainer.lineBreakMode = self.lineBreakMode;
[layoutManager addTextContainer:textContainer];
return [layoutManager characterIndexForPoint:tapLocation
inTextContainer:textContainer
fractionOfDistanceBetweenInsertionPoints:NULL];
}
@end