diff --git a/Limelight/AppDelegate.h b/Limelight/AppDelegate.h index 90deb407..06b3bd6b 100644 --- a/Limelight/AppDelegate.h +++ b/Limelight/AppDelegate.h @@ -12,6 +12,7 @@ @property (strong, nonatomic) UIWindow *window; @property (strong, nonatomic) NSString *pcUuidToLoad; +@property (strong, nonatomic) NSString *appToRun; @property (strong, nonatomic) void (^shortcutCompletionHandler)(BOOL); @property (readonly, strong, nonatomic) NSManagedObjectContext *managedObjectContext; diff --git a/Limelight/AppDelegate.m b/Limelight/AppDelegate.m index 5db73f57..7ab539c0 100644 --- a/Limelight/AppDelegate.m +++ b/Limelight/AppDelegate.m @@ -39,6 +39,30 @@ - (void)application:(UIApplication *)application performActionForShortcutItem:(U } #endif +- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options { + if ([url.host isEqualToString:@"appClicked"]) { + NSString *query = [url query]; + NSArray *params = [query componentsSeparatedByString:@"&"]; + NSMutableDictionary *queryParameters = [NSMutableDictionary dictionary]; + + for (NSString *param in params) { + NSArray *keyValue = [param componentsSeparatedByString:@"="]; + if ([keyValue count] == 2) { + queryParameters[keyValue[0]] = keyValue[1]; + } + } + + NSString *appId = queryParameters[@"app"]; + NSString *UUID = queryParameters[@"UUID"]; + + _pcUuidToLoad = UUID; + _appToRun = appId; + + return YES; + } + return NO; +} + - (void)applicationWillResignActive:(UIApplication *)application { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. diff --git a/Limelight/Database/DataManager.h b/Limelight/Database/DataManager.h index 5717a795..0f417a17 100644 --- a/Limelight/Database/DataManager.h +++ b/Limelight/Database/DataManager.h @@ -35,7 +35,9 @@ - (void) updateAppsForExistingHost:(TemporaryHost *)host; - (void) removeHost:(TemporaryHost*)host; - (void) removeApp:(TemporaryApp*)app; - +#if TARGET_OS_TV +- (void)moveAppUpInList:(NSString *)appId; +#endif - (TemporarySettings*) getSettings; - (void) updateUniqueId:(NSString*)uniqueId; diff --git a/Limelight/Database/DataManager.m b/Limelight/Database/DataManager.m index 0f5edeb1..ca61d177 100644 --- a/Limelight/Database/DataManager.m +++ b/Limelight/Database/DataManager.m @@ -108,6 +108,58 @@ - (void) updateHost:(TemporaryHost *)host { }]; } +#if TARGET_OS_TV + +- (NSDictionary *)dictionaryFromApp:(App *)app { + return @{@"hostUUID": app.host.uuid, @"hostName": app.host.name, @"name": app.name, @"id": app.id }; +} + +- (void)moveAppUpInList:(NSString *)appId { + NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.MoonlightTV"]; + NSString *json = [sharedDefaults objectForKey:@"appList"]; + NSData *jsonData = [json dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableArray *apps = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:nil]; + + // Identify the selected app and its index + NSDictionary *selectedApp = nil; + NSInteger selectedIndex = NSNotFound; + for (NSDictionary *app in apps) { + if ([app[@"id"] isEqualToString:appId]) { + selectedApp = app; + selectedIndex = [apps indexOfObject:app]; + break; + } + } + + if (selectedApp && selectedIndex != NSNotFound) { + // Move the app to the top of the list + [apps removeObjectAtIndex:selectedIndex]; + [apps insertObject:selectedApp atIndex:0]; + + // Serialize to JSON and save back to user defaults + NSData *newJsonData = [NSJSONSerialization dataWithJSONObject:apps options:0 error:nil]; + NSString *newJsonStr = [[NSString alloc] initWithData:newJsonData encoding:NSUTF8StringEncoding]; + [sharedDefaults setObject:newJsonStr forKey:@"appList"]; + + // Update hostUUIDOrder accordingly + NSString *hostUUID = selectedApp[@"hostUUID"]; + NSMutableArray *hostUUIDOrder = [[sharedDefaults objectForKey:@"hostUUIDOrder"] mutableCopy]; + if (!hostUUIDOrder) { + hostUUIDOrder = [NSMutableArray array]; + } + [hostUUIDOrder removeObject:hostUUID]; + [hostUUIDOrder insertObject:hostUUID atIndex:0]; + [sharedDefaults setObject:hostUUIDOrder forKey:@"hostUUIDOrder"]; + + // Synchronize changes + [sharedDefaults synchronize]; + + } else { + NSLog(@"App with ID %@ not found.", appId); + } +} +#endif + - (void) updateAppsForExistingHost:(TemporaryHost *)host { [_managedObjectContext performBlockAndWait:^{ Host* parent = [self getHostForTemporaryHost:host withHostRecords:[self fetchRecords:@"Host"]]; @@ -177,6 +229,8 @@ - (void) removeHost:(TemporaryHost*)host { if (managedHost != nil) { [self->_managedObjectContext deleteObject:managedHost]; [self saveData]; + + } }]; } @@ -186,8 +240,124 @@ - (void) saveData { if ([_managedObjectContext hasChanges] && ![_managedObjectContext save:&error]) { Log(LOG_E, @"Unable to save hosts to database: %@", error); } - [_appDelegate saveContext]; + + +#if TARGET_OS_TV + // Save hosts/apps for Top Shelf + NSArray *hosts = [self fetchRecords:@"Host"]; + NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.MoonlightTV"]; + NSString *existingJson = [sharedDefaults objectForKey:@"appList"]; + + // Retrieve the existing order of host UUIDs + NSMutableArray *storedUUIDOrder = [[sharedDefaults objectForKey:@"hostUUIDOrder"] mutableCopy]; + if (!storedUUIDOrder) { + storedUUIDOrder = [NSMutableArray array]; + } + // Update storedUUIDOrder if new hosts are added + for (Host* host in hosts) { + if (![storedUUIDOrder containsObject:host.uuid]) { + [storedUUIDOrder addObject:host.uuid]; + } + } + // Save the updated order back to User Defaults + [sharedDefaults setObject:storedUUIDOrder forKey:@"hostUUIDOrder"]; + [sharedDefaults synchronize]; + + // Sort hosts by order + hosts = [hosts sortedArrayUsingComparator:^NSComparisonResult(Host* a, Host* b) { + NSUInteger first = [storedUUIDOrder indexOfObject:a.uuid]; + NSUInteger second = [storedUUIDOrder indexOfObject:b.uuid]; + if (first < second) { + return NSOrderedAscending; + } else if (first > second) { + return NSOrderedDescending; + } else { + return NSOrderedSame; + } + }]; + + NSArray *existingApps; + if (existingJson != nil) { + existingApps = [NSJSONSerialization JSONObjectWithData:[existingJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; + } else { + existingApps = [NSArray array]; + } + + NSMutableArray *mutableExistingApps = [existingApps mutableCopy]; + NSMutableSet *currentHostUUIDs = [NSMutableSet set]; + + for (Host* host in hosts) { + + [currentHostUUIDs addObject:host.uuid]; + + if ([host.appList count]>0) { + NSMutableDictionary *hostAppMap = [NSMutableDictionary dictionary]; + for (NSDictionary *app in existingApps) { + hostAppMap[app[@"hostUUID"]] = app; + } + + NSMutableSet *currentAppIds = [NSMutableSet set]; + + for (App *app in host.appList) { + [currentAppIds addObject:app.id]; + NSDictionary *newAppDict = [self dictionaryFromApp:app]; + NSUInteger existingIndex = [mutableExistingApps indexOfObjectPassingTest:^BOOL(NSDictionary *dict, NSUInteger idx, BOOL *stop) { + return [dict[@"id"] isEqualToString:app.id]; + }]; + + if (existingIndex != NSNotFound) { + mutableExistingApps[existingIndex] = newAppDict; + } else { + [mutableExistingApps addObject:newAppDict]; + } + } + + // Removing apps not in source list for this host + NSIndexSet *indexesToDelete = [mutableExistingApps indexesOfObjectsPassingTest:^BOOL(NSDictionary *dict, NSUInteger idx, BOOL *stop) { + return ![currentAppIds containsObject:dict[@"id"]] && [dict[@"hostUUID"] isEqualToString:host.uuid]; + }]; + [mutableExistingApps removeObjectsAtIndexes:indexesToDelete]; + } + } + + // Remove apps belonging to hosts that are no longer there + NSIndexSet *indexesToDelete = [mutableExistingApps indexesOfObjectsPassingTest:^BOOL(NSDictionary *dict, NSUInteger idx, BOOL *stop) { + return ![currentHostUUIDs containsObject:dict[@"hostUUID"]]; + }]; + [mutableExistingApps removeObjectsAtIndexes:indexesToDelete]; + + // Partition into separate arrays + NSMutableDictionary *hostToAppsMap = [NSMutableDictionary new]; + for (NSDictionary *app in mutableExistingApps) { + NSString *hostUUID = app[@"hostUUID"]; + if (!hostToAppsMap[hostUUID]) { + hostToAppsMap[hostUUID] = [NSMutableArray new]; + } + [hostToAppsMap[hostUUID] addObject:app]; + } + + // Sort these arrays + NSArray *sortedKeys = [hostToAppsMap.allKeys sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + NSUInteger first = [storedUUIDOrder indexOfObject:a]; + NSUInteger second = [storedUUIDOrder indexOfObject:b]; + return first < second ? NSOrderedAscending : NSOrderedDescending; + }]; + + // Merge them back + [mutableExistingApps removeAllObjects]; + for (NSString *key in sortedKeys) { + [mutableExistingApps addObjectsFromArray:hostToAppsMap[key]]; + } + + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:mutableExistingApps options:0 error:&error]; + if (jsonData) { + NSString *jsonStr = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + [sharedDefaults setObject:jsonStr forKey:@"appList"]; + [sharedDefaults synchronize]; + } + +#endif } - (NSArray*) getHosts { diff --git a/Limelight/UIAppView.m b/Limelight/UIAppView.m index 6ad9cf48..293fec92 100644 --- a/Limelight/UIAppView.m +++ b/Limelight/UIAppView.m @@ -142,6 +142,23 @@ - (void) updateAppImage { if (!(appImage.size.width == 130.f && appImage.size.height == 180.f) && // GFE 2.0 !(appImage.size.width == 628.f && appImage.size.height == 888.f)) { // GFE 3.0 [_appImage setImage:appImage]; + + // Save to caches for Top Shelf + NSURL *url = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.MoonlightTV"]; + if (url && appImage) { + NSURL *cachesURL = [url URLByAppendingPathComponent:@"Library" isDirectory:YES]; + cachesURL = [cachesURL URLByAppendingPathComponent:@"Caches" isDirectory:YES]; + // Construct the file path + NSString *filePath = [cachesURL path]; + filePath = [filePath stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-%@", _app.host.uuid, _app.id]]; + filePath = [filePath stringByAppendingPathExtension:@"png"]; + // Convert NSString to NSURL + NSURL *imageURL = [NSURL fileURLWithPath:filePath]; + NSData *imageData = UIImagePNGRepresentation(appImage); + NSError *error = nil; + [imageData writeToURL:imageURL atomically:YES]; + } + } else { noAppImage = true; } diff --git a/Limelight/ViewControllers/MainFrameViewController.m b/Limelight/ViewControllers/MainFrameViewController.m index 169bb13b..fddff377 100644 --- a/Limelight/ViewControllers/MainFrameViewController.m +++ b/Limelight/ViewControllers/MainFrameViewController.m @@ -204,6 +204,19 @@ - (void)alreadyPaired { [self->_appManager stopRetrieving]; [self->_appManager retrieveAssetsFromHost:host]; [self hideLoadingFrame: nil]; + + AppDelegate* delegate = (AppDelegate*)[UIApplication sharedApplication].delegate; + if (delegate.appToRun != nil) + { + for (TemporaryApp *app in host.appList) { + if ([app.id isEqualToString:delegate.appToRun]) { + [self appClicked:app view:nil]; + break; + } + } + delegate.appToRun = nil; + } + }); } }); @@ -850,8 +863,13 @@ - (void) appClicked:(TemporaryApp *)app view:(UIView *)view { // before we call prepareToStreamApp. [[self revealViewController] revealToggleAnimated:NO]; } -#endif +#else + + DataManager* database = [[DataManager alloc] init]; + [database moveAppUpInList:app.id]; +#endif + if ([self findRunningApp:app.host]) { // If there's a running app, display a menu [self appLongClicked:app view:view]; diff --git a/Moonlight TV/Info.plist b/Moonlight TV/Info.plist index b91ee818..13983d61 100644 --- a/Moonlight TV/Info.plist +++ b/Moonlight TV/Info.plist @@ -56,5 +56,16 @@ UIUserInterfaceStyle Dark + CFBundleURLTypes + + + CFBundleURLSchemes + + moonlight + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + + diff --git a/Moonlight TV/Moonlight TV.entitlements b/Moonlight TV/Moonlight TV.entitlements new file mode 100644 index 00000000..b12d118a --- /dev/null +++ b/Moonlight TV/Moonlight TV.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.MoonlightTV + + + diff --git a/Moonlight.xcodeproj/project.pbxproj b/Moonlight.xcodeproj/project.pbxproj index 6f055579..22a3a34d 100644 --- a/Moonlight.xcodeproj/project.pbxproj +++ b/Moonlight.xcodeproj/project.pbxproj @@ -42,6 +42,8 @@ 98D5856D1C0EA79600F6CC00 /* TemporaryHost.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856C1C0EA79600F6CC00 /* TemporaryHost.m */; }; 98D585701C0ED0E800F6CC00 /* TemporarySettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856F1C0ED0E800F6CC00 /* TemporarySettings.m */; }; DC1F5A07206436B20037755F /* ConnectionHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = DC1F5A06206436B20037755F /* ConnectionHelper.m */; }; + DDB750772A9D3DE000526F05 /* ContentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB750762A9D3DE000526F05 /* ContentProvider.swift */; }; + DDB7507B2A9D3DE000526F05 /* Shelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DDB750722A9D3DE000526F05 /* Shelf.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F74BEF9B2C1A705600224667 /* OpenSSL */; }; F74BEFA42C1A80E400224667 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F74BEFA32C1A80E400224667 /* OpenSSL */; }; FB1A674D2131E65900507771 /* KeyboardSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1A674C2131E65900507771 /* KeyboardSupport.m */; }; @@ -153,6 +155,13 @@ remoteGlobalIDString = FB290E2D19B37A4E004C83CF; remoteInfo = "moonlight-common"; }; + DDB750792A9D3DE000526F05 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FB290CE619B2C406004C83CF /* Project object */; + proxyType = 1; + remoteGlobalIDString = DDB750712A9D3DE000526F05; + remoteInfo = Shelf; + }; FB1A68142132509400507771 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 98AB2E7F1CAD46830089BB98 /* moonlight-common.xcodeproj */; @@ -169,6 +178,20 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + DDB7507F2A9D3DE000526F05 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DDB7507B2A9D3DE000526F05 /* Shelf.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 566E9D2B2770B23A00EF7BFE /* Moonlight v1.7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.7.xcdatamodel"; sourceTree = ""; }; 693B3A9A218638CD00982F7B /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -215,6 +238,13 @@ D4746EEA1CBC740C006FB401 /* Moonlight-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Moonlight-Bridging-Header.h"; path = "Input/Moonlight-Bridging-Header.h"; sourceTree = ""; }; DC1F5A05206436B10037755F /* ConnectionHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConnectionHelper.h; sourceTree = ""; }; DC1F5A06206436B20037755F /* ConnectionHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConnectionHelper.m; sourceTree = ""; }; + DD4F2B122A9EB91E00C98CC2 /* Moonlight TV.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Moonlight TV.entitlements"; sourceTree = ""; }; + DD4F2B132A9EB95600C98CC2 /* Shelf.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Shelf.entitlements; sourceTree = ""; }; + DD65E6472AA13C5F002304D6 /* NoAppImage.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = NoAppImage.png; sourceTree = SOURCE_ROOT; }; + DDB750722A9D3DE000526F05 /* Shelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Shelf.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DDB750732A9D3DE000526F05 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; }; + DDB750762A9D3DE000526F05 /* ContentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProvider.swift; sourceTree = ""; }; + DDB750782A9D3DE000526F05 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FB1A674B2131E65900507771 /* KeyboardSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyboardSupport.h; sourceTree = ""; }; FB1A674C2131E65900507771 /* KeyboardSupport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KeyboardSupport.m; sourceTree = ""; }; FB1A67532132419700507771 /* Moonlight TV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Moonlight TV.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -323,6 +353,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + DDB7506F2A9D3DE000526F05 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DDB750742A9D3DE000526F05 /* TVServices.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FB1A67502132419700507771 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -382,9 +420,21 @@ name = Products; sourceTree = ""; }; + DDB750752A9D3DE000526F05 /* Shelf */ = { + isa = PBXGroup; + children = ( + DD65E6472AA13C5F002304D6 /* NoAppImage.png */, + DD4F2B132A9EB95600C98CC2 /* Shelf.entitlements */, + DDB750762A9D3DE000526F05 /* ContentProvider.swift */, + DDB750782A9D3DE000526F05 /* Info.plist */, + ); + path = Shelf; + sourceTree = ""; + }; FB1A67542132419700507771 /* Moonlight TV */ = { isa = PBXGroup; children = ( + DD4F2B122A9EB91E00C98CC2 /* Moonlight TV.entitlements */, FB1A675E2132419700507771 /* Main.storyboard */, FB1A67612132419A00507771 /* Assets.xcassets */, FB1A67632132419A00507771 /* Info.plist */, @@ -401,6 +451,7 @@ 98AB2E7F1CAD46830089BB98 /* moonlight-common.xcodeproj */, FB290CF919B2C406004C83CF /* Moonlight */, FB1A67542132419700507771 /* Moonlight TV */, + DDB750752A9D3DE000526F05 /* Shelf */, FB290CF019B2C406004C83CF /* Frameworks */, FB290CEF19B2C406004C83CF /* Products */, ); @@ -411,6 +462,7 @@ children = ( FB290CEE19B2C406004C83CF /* Moonlight.app */, FB1A67532132419700507771 /* Moonlight TV.app */, + DDB750722A9D3DE000526F05 /* Shelf.appex */, ); name = Products; sourceTree = ""; @@ -438,6 +490,7 @@ FB290CF319B2C406004C83CF /* CoreGraphics.framework */, FB290CF519B2C406004C83CF /* UIKit.framework */, FB290CF719B2C406004C83CF /* CoreData.framework */, + DDB750732A9D3DE000526F05 /* TVServices.framework */, ); name = Frameworks; sourceTree = ""; @@ -688,6 +741,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + DDB750712A9D3DE000526F05 /* Shelf */ = { + isa = PBXNativeTarget; + buildConfigurationList = DDB7507C2A9D3DE000526F05 /* Build configuration list for PBXNativeTarget "Shelf" */; + buildPhases = ( + DDB7506E2A9D3DE000526F05 /* Sources */, + DDB7506F2A9D3DE000526F05 /* Frameworks */, + DDB750702A9D3DE000526F05 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Shelf; + productName = Shelf; + productReference = DDB750722A9D3DE000526F05 /* Shelf.appex */; + productType = "com.apple.product-type.app-extension"; + }; FB1A67522132419700507771 /* Moonlight TV */ = { isa = PBXNativeTarget; buildConfigurationList = FB1A67682132419A00507771 /* Build configuration list for PBXNativeTarget "Moonlight TV" */; @@ -695,11 +765,13 @@ FB1A674F2132419700507771 /* Sources */, FB1A67502132419700507771 /* Frameworks */, FB1A67512132419700507771 /* Resources */, + DDB7507F2A9D3DE000526F05 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( FB1A68172132509800507771 /* PBXTargetDependency */, + DDB7507A2A9D3DE000526F05 /* PBXTargetDependency */, ); name = "Moonlight TV"; packageProductDependencies = ( @@ -736,10 +808,14 @@ FB290CE619B2C406004C83CF /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0730; + LastSwiftUpdateCheck = 1430; LastUpgradeCheck = 1400; ORGANIZATIONNAME = "Moonlight Game Streaming Project"; TargetAttributes = { + DDB750712A9D3DE000526F05 = { + CreatedOnToolsVersion = 14.3.1; + LastSwiftMigration = 1430; + }; FB1A67522132419700507771 = { CreatedOnToolsVersion = 9.4.1; LastSwiftMigration = 1140; @@ -755,7 +831,7 @@ }; }; }; - buildConfigurationList = FB290CE919B2C406004C83CF /* Build configuration list for PBXProject "Moonlight" */; + buildConfigurationList = FB290CE919B2C406004C83CF /* Build configuration list for PBXProject "Moonlight macOS" */; compatibilityVersion = "Xcode 8.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -779,6 +855,7 @@ targets = ( FB290CED19B2C406004C83CF /* Moonlight */, FB1A67522132419700507771 /* Moonlight TV */, + DDB750712A9D3DE000526F05 /* Shelf */, ); }; /* End PBXProject section */ @@ -801,6 +878,14 @@ /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ + DDB750702A9D3DE000526F05 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DD65E6482AA13C5F002304D6 /* NoAppImage.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FB1A67512132419700507771 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -827,6 +912,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + DDB7506E2A9D3DE000526F05 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DDB750772A9D3DE000526F05 /* ContentProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FB1A674F2132419700507771 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -944,6 +1037,11 @@ name = "moonlight-common"; targetProxy = 98AB2E851CAD468B0089BB98 /* PBXContainerItemProxy */; }; + DDB7507A2A9D3DE000526F05 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DDB750712A9D3DE000526F05 /* Shelf */; + targetProxy = DDB750792A9D3DE000526F05 /* PBXContainerItemProxy */; + }; FB1A68172132509800507771 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = "moonlight-common-tv"; @@ -971,9 +1069,95 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + DDB7507D2A9D3DE000526F05 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Shelf/Shelf.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = DM46QST4M7; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Shelf/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Shelf; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Moonlight Game Streaming Project. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.moonlight-stream.Moonlight.Shelf"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Limelight/Input/Moonlight-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 16.4; + }; + name = Debug; + }; + DDB7507E2A9D3DE000526F05 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Shelf/Shelf.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = DM46QST4M7; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Shelf/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Shelf; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Moonlight Game Streaming Project. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.moonlight-stream.Moonlight.Shelf"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Limelight/Input/Moonlight-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 16.4; + }; + name = Release; + }; FB1A67662132419A00507771 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = "Launch Image"; CLANG_ANALYZER_NONNULL = YES; @@ -981,6 +1165,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "Moonlight TV/Moonlight TV.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; @@ -1029,6 +1214,7 @@ FB1A67672132419A00507771 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = "Launch Image"; CLANG_ANALYZER_NONNULL = YES; @@ -1036,6 +1222,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "Moonlight TV/Moonlight TV.entitlements"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; @@ -1296,6 +1483,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + DDB7507C2A9D3DE000526F05 /* Build configuration list for PBXNativeTarget "Shelf" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DDB7507D2A9D3DE000526F05 /* Debug */, + DDB7507E2A9D3DE000526F05 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; FB1A67682132419A00507771 /* Build configuration list for PBXNativeTarget "Moonlight TV" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1305,7 +1501,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FB290CE919B2C406004C83CF /* Build configuration list for PBXProject "Moonlight" */ = { + FB290CE919B2C406004C83CF /* Build configuration list for PBXProject "Moonlight macOS" */ = { isa = XCConfigurationList; buildConfigurations = ( FB290D1E19B2C406004C83CF /* Debug */, diff --git a/NoAppImage.png b/NoAppImage.png new file mode 100644 index 00000000..59eb1e97 Binary files /dev/null and b/NoAppImage.png differ diff --git a/Shelf/ContentProvider.swift b/Shelf/ContentProvider.swift new file mode 100644 index 00000000..12a3796c --- /dev/null +++ b/Shelf/ContentProvider.swift @@ -0,0 +1,123 @@ +// +// ContentProvider.swift +// Shelf +// +// Created by Anastasy on 8/28/23. +// Copyright © 2023 Moonlight Game Streaming Project. All rights reserved. +// + +import TVServices +import UIKit + +class MyTopShelfContent: NSObject, TVTopShelfContent, NSSecureCoding { + static var supportsSecureCoding: Bool { + return true + } + + var items: [TVTopShelfItem] = [] + + override init() { + super.init() + } + + required init?(coder: NSCoder) { + super.init() + self.items = coder.decodeObject(forKey: "items") as? [TVTopShelfItem] ?? [] + } + + func encode(with coder: NSCoder) { + coder.encode(self.items, forKey: "items") + } +} + +func urlForImage(named name: String) -> URL? { + + if let fileURL = Bundle.main.url(forResource: name, withExtension: "png") { + return fileURL + } + + return nil + +} + +func urlForCachedImage(uuid: String, appId: String) -> URL? { + let appGroupIdentifier = "group.MoonlightTV" + let imageName = "\(uuid)-\(appId)" + + guard + let url = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { + return nil + } + + let cachesURL = + url + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Caches", isDirectory: true) + .appendingPathComponent(imageName) + .appendingPathExtension("png") + + return FileManager.default.fileExists(atPath: cachesURL.path) ? cachesURL : nil +} + +class ContentProvider: TVTopShelfContentProvider { + + override func loadTopShelfContent(completionHandler: @escaping (TVTopShelfContent?) -> Void) { + var hostSections: [String: [TVTopShelfSectionedItem]] = [:] + + if let sharedDefaults = UserDefaults(suiteName: "group.MoonlightTV"), + let jsonString = sharedDefaults.string(forKey: "appList"), + let jsonData = jsonString.data(using: .utf8) + { + do { + if let appList = try JSONSerialization.jsonObject(with: jsonData, options: []) + as? [[String: Any]] + { + for appDict in appList { + if let appId = appDict["id"] as? String, + let hostName = appDict["hostName"] as? String, + let hostUUID = appDict["hostUUID"] as? String + { + let item = TVTopShelfSectionedItem(identifier: appId) + item.title = appDict["name"] as? String + + let defaultImageURL = urlForImage(named: "NoAppImage") + item.setImageURL(defaultImageURL, for: [.screenScale1x, .screenScale2x]) + + if let cachedImageURL = urlForCachedImage(uuid: hostUUID, appId: appId) { + item.setImageURL(cachedImageURL, for: [.screenScale1x, .screenScale2x]) + } + + hostSections[hostName, default: []].append(item) + + if let actionURL = URL(string: "moonlight://appClicked?app=\(appId)&UUID=\(hostUUID)") + { + let action = TVTopShelfAction(url: actionURL) + item.playAction = action + item.displayAction = action + } + } + } + } + var sectionedItemCollections: [TVTopShelfItemCollection] = [] + + for (hostName, items) in hostSections { + let itemCollection = TVTopShelfItemCollection(items: items) + itemCollection.title = "Moonlight: \(hostName)" + sectionedItemCollections.append(itemCollection) + } + + let sectionedContent = TVTopShelfSectionedContent(sections: sectionedItemCollections) + completionHandler(sectionedContent) + + } catch { + print("appList deserialization failed: \(error)") + completionHandler(nil) + } + } else { + completionHandler(nil) + } + + } +} diff --git a/Shelf/Info.plist b/Shelf/Info.plist new file mode 100644 index 00000000..6f49c9c3 --- /dev/null +++ b/Shelf/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.tv-top-shelf + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ContentProvider + + + diff --git a/Shelf/Shelf.entitlements b/Shelf/Shelf.entitlements new file mode 100644 index 00000000..b12d118a --- /dev/null +++ b/Shelf/Shelf.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.MoonlightTV + + +