feat(tvos): Add TopShelf Extension (#1561)

This commit is contained in:
Steve Byatt
2026-05-21 07:47:45 +01:00
committed by GitHub
parent 4bef386b82
commit 121ff0eea0
19 changed files with 832 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["TopShelfCacheModule"]
}
}

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'TopShelfCache'
s.version = '1.0.0'
s.summary = 'Shared Top Shelf cache writer for Streamyfin tvOS'
s.description = 'Writes lightweight Top Shelf cache payloads to an App Group container for the tvOS extension.'
s.author = 'Streamyfin'
s.homepage = 'https://github.com/streamyfin/streamyfin'
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_VERSION' => '5.9'
}
s.source_files = "*.{h,m,mm,swift}"
end

View File

@@ -0,0 +1,112 @@
import ExpoModulesCore
import Foundation
import Security
#if canImport(TVServices)
import TVServices
#endif
public class TopShelfCacheModule: Module {
private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier"
private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier"
private let cacheKey = "TopShelfCache"
private let apiKeyService = "StreamyfinTopShelf"
private let apiKeyAccount = "JellyfinApiKey"
private var appGroupIdentifier: String? {
if let appGroupIdentifier = Bundle.main.object(
forInfoDictionaryKey: appGroupInfoPlistKey
) as? String {
return appGroupIdentifier
}
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
return nil
}
return "group.\(bundleIdentifier)"
}
private var keychainAccessGroupIdentifier: String? {
Bundle.main.object(forInfoDictionaryKey: keychainAccessGroupInfoPlistKey) as? String
}
public func definition() -> ModuleDefinition {
Name("TopShelfCache")
Function("writeCache") { (json: String, apiKey: String?) -> Bool in
guard
let appGroupIdentifier = appGroupIdentifier,
let defaults = UserDefaults(suiteName: appGroupIdentifier)
else {
return false
}
defaults.set(json, forKey: cacheKey)
defaults.set(Date().timeIntervalSince1970, forKey: "\(cacheKey)UpdatedAt")
defaults.synchronize()
let didSaveAPIKey = saveAPIKey(apiKey)
#if canImport(TVServices)
TVTopShelfContentProvider.topShelfContentDidChange()
#endif
return didSaveAPIKey
}
Function("clearCache") { () -> Bool in
guard
let appGroupIdentifier = appGroupIdentifier,
let defaults = UserDefaults(suiteName: appGroupIdentifier)
else {
return false
}
defaults.removeObject(forKey: cacheKey)
defaults.removeObject(forKey: "\(cacheKey)UpdatedAt")
defaults.synchronize()
let didDeleteAPIKey = deleteAPIKey()
#if canImport(TVServices)
TVTopShelfContentProvider.topShelfContentDidChange()
#endif
return didDeleteAPIKey
}
}
private func baseAPIKeyQuery() -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: apiKeyService,
kSecAttrAccount as String: apiKeyAccount
]
if let keychainAccessGroupIdentifier {
query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier
}
return query
}
private func saveAPIKey(_ apiKey: String?) -> Bool {
guard deleteAPIKey() else {
return false
}
guard
let apiKey,
!apiKey.isEmpty,
let data = apiKey.data(using: .utf8)
else {
return true
}
var query = baseAPIKeyQuery()
query[kSecValueData as String] = data
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
private func deleteAPIKey() -> Bool {
let status = SecItemDelete(baseAPIKeyQuery() as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}

View File

@@ -0,0 +1,21 @@
export type TopShelfCacheModuleEvents = Record<string, never>;
export interface TopShelfCacheItem {
id: string;
title: string;
subtitle?: string;
imageUrl?: string;
route: string;
playRoute?: string;
}
export interface TopShelfCacheSection {
title: string;
items: TopShelfCacheItem[];
}
export interface TopShelfCachePayload {
version: 1;
updatedAt: string;
sections: TopShelfCacheSection[];
}

View File

@@ -0,0 +1,37 @@
import { NativeModule, requireNativeModule } from "expo";
import { Platform } from "react-native";
import type { TopShelfCacheModuleEvents } from "./TopShelfCache.types";
declare class TopShelfCacheModuleType extends NativeModule<TopShelfCacheModuleEvents> {
writeCache(json: string, apiKey?: string): boolean;
clearCache(): boolean;
}
let TopShelfCacheNativeModule: TopShelfCacheModuleType | null = null;
if (Platform.OS === "ios" && Platform.isTV) {
try {
TopShelfCacheNativeModule =
requireNativeModule<TopShelfCacheModuleType>("TopShelfCache");
} catch {
TopShelfCacheNativeModule = null;
}
}
export function writeTopShelfCache(json: string, apiKey?: string): boolean {
if (!TopShelfCacheNativeModule) return false;
try {
return TopShelfCacheNativeModule.writeCache(json, apiKey);
} catch {
try {
return TopShelfCacheNativeModule.writeCache(json);
} catch {
return false;
}
}
}
export function clearTopShelfCache(): boolean {
return TopShelfCacheNativeModule?.clearCache() ?? false;
}

View File

@@ -0,0 +1,2 @@
export * from "./TopShelfCache.types";
export { clearTopShelfCache, writeTopShelfCache } from "./TopShelfCacheModule";