diff --git a/app.json b/app.json index 668c6163..f9dfbb6d 100644 --- a/app.json +++ b/app.json @@ -76,6 +76,7 @@ "expo-router", "expo-font", "./plugins/withExcludeMedia3Dash.js", + "./plugins/withTVUserManagement.js", [ "expo-build-properties", { diff --git a/app/_layout.tsx b/app/_layout.tsx index 43fe2186..cd6b6de5 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -11,7 +11,6 @@ import * as Device from "expo-device"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; - import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 182d25d9..0453ed9c 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -390,12 +390,28 @@ export const TVLogin: React.FC = () => { pinCode?: string, ) => { setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); + + if (pendingLogin && currentServer) { + setLoading(true); + try { + await login(pendingLogin.username, pendingLogin.password, serverName, { + saveAccount: true, + securityType, + pinCode, + }); + } catch (error) { + if (error instanceof Error) { + Alert.alert(t("login.connection_failed"), error.message); + } else { + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured"), + ); + } + } finally { + setLoading(false); + setPendingLogin(null); + } } }; diff --git a/modules/tv-user-profile/expo-module.config.json b/modules/tv-user-profile/expo-module.config.json new file mode 100644 index 00000000..6b34d793 --- /dev/null +++ b/modules/tv-user-profile/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "tv-user-profile", + "version": "1.0.0", + "platforms": ["apple"], + "apple": { + "modules": ["TvUserProfileModule"] + } +} diff --git a/modules/tv-user-profile/index.ts b/modules/tv-user-profile/index.ts new file mode 100644 index 00000000..a0672c72 --- /dev/null +++ b/modules/tv-user-profile/index.ts @@ -0,0 +1,103 @@ +import type { EventSubscription } from "expo-modules-core"; +import { Platform, requireNativeModule } from "expo-modules-core"; + +interface TvUserProfileModuleEvents { + onProfileChange: (event: { profileId: string | null }) => void; +} + +interface TvUserProfileModuleType { + getCurrentProfileId(): string | null; + isProfileSwitchingSupported(): boolean; + presentUserPicker(): Promise; + addListener( + eventName: K, + listener: TvUserProfileModuleEvents[K], + ): EventSubscription; +} + +// Only load the native module on Apple platforms +const TvUserProfileModule: TvUserProfileModuleType | null = + Platform.OS === "ios" + ? requireNativeModule("TvUserProfile") + : null; + +/** + * Get the current tvOS profile identifier. + * Returns null on non-tvOS platforms or if no profile is active. + */ +export function getCurrentProfileId(): string | null { + if (!TvUserProfileModule) { + return null; + } + + try { + return TvUserProfileModule.getCurrentProfileId() ?? null; + } catch (error) { + console.error("[TvUserProfile] Error getting profile ID:", error); + return null; + } +} + +/** + * Check if tvOS profile switching is supported on this device. + * Returns true only on tvOS. + */ +export function isProfileSwitchingSupported(): boolean { + if (!TvUserProfileModule) { + return false; + } + + try { + return TvUserProfileModule.isProfileSwitchingSupported(); + } catch (error) { + console.error("[TvUserProfile] Error checking profile support:", error); + return false; + } +} + +/** + * Subscribe to profile change events. + * The callback receives the new profile ID (or null if no profile). + * Returns an unsubscribe function. + */ +export function addProfileChangeListener( + callback: (profileId: string | null) => void, +): () => void { + if (!TvUserProfileModule) { + // Return no-op unsubscribe on unsupported platforms + return () => {}; + } + + const subscription = TvUserProfileModule.addListener( + "onProfileChange", + (event) => { + callback(event.profileId); + }, + ); + + return () => subscription.remove(); +} + +/** + * Present the system user picker panel. + * Returns true if successful, false otherwise. + */ +export async function presentUserPicker(): Promise { + if (!TvUserProfileModule) { + return false; + } + + try { + return await TvUserProfileModule.presentUserPicker(); + } catch (error) { + console.error("[TvUserProfile] Error presenting user picker:", error); + return false; + } +} + +export default { + getCurrentProfileId, + isProfileSwitchingSupported, + addProfileChangeListener, + presentUserPicker, +}; diff --git a/modules/tv-user-profile/ios/TvUserProfile.podspec b/modules/tv-user-profile/ios/TvUserProfile.podspec new file mode 100644 index 00000000..648af143 --- /dev/null +++ b/modules/tv-user-profile/ios/TvUserProfile.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'TvUserProfile' + s.version = '1.0.0' + s.summary = 'tvOS User Profile Management for Expo' + s.description = 'Native tvOS module to get current user profile and listen for profile changes using TVUserManager' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { :ios => '15.6', :tvos => '15.0' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # TVServices framework is only available on tvOS + s.tvos.frameworks = 'TVServices' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/tv-user-profile/ios/TvUserProfileModule.swift b/modules/tv-user-profile/ios/TvUserProfileModule.swift new file mode 100644 index 00000000..1885a811 --- /dev/null +++ b/modules/tv-user-profile/ios/TvUserProfileModule.swift @@ -0,0 +1,103 @@ +import ExpoModulesCore +#if os(tvOS) +import TVServices +#endif + +public class TvUserProfileModule: Module { + #if os(tvOS) + private let userManager = TVUserManager() + private var profileObservation: NSKeyValueObservation? + #endif + + public func definition() -> ModuleDefinition { + Name("TvUserProfile") + + // Define event that can be sent to JavaScript + Events("onProfileChange") + + // Get current tvOS profile identifier + Function("getCurrentProfileId") { () -> String? in + #if os(tvOS) + let identifier = self.userManager.currentUserIdentifier + print("[TvUserProfile] Current profile ID: \(identifier ?? "nil")") + return identifier + #else + return nil + #endif + } + + // Check if running on tvOS with profile support + Function("isProfileSwitchingSupported") { () -> Bool in + #if os(tvOS) + return true + #else + return false + #endif + } + + // Present the system user picker + AsyncFunction("presentUserPicker") { () -> Bool in + #if os(tvOS) + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.userManager.presentProfilePreferencePanel { error in + if let error = error { + print("[TvUserProfile] Error presenting user picker: \(error)") + continuation.resume(returning: false) + } else { + print("[TvUserProfile] User picker presented, new ID: \(self.userManager.currentUserIdentifier ?? "nil")") + continuation.resume(returning: true) + } + } + } + } + #else + return false + #endif + } + + OnCreate { + #if os(tvOS) + self.setupProfileObserver() + #endif + } + + OnDestroy { + #if os(tvOS) + self.profileObservation?.invalidate() + self.profileObservation = nil + #endif + } + } + + #if os(tvOS) + private func setupProfileObserver() { + // Debug: Print all available info about TVUserManager + print("[TvUserProfile] TVUserManager created") + print("[TvUserProfile] currentUserIdentifier: \(userManager.currentUserIdentifier ?? "nil")") + if #available(tvOS 16.0, *) { + print("[TvUserProfile] shouldStorePreferencesForCurrentUser: \(userManager.shouldStorePreferencesForCurrentUser)") + } + + // Set up KVO observation on currentUserIdentifier + profileObservation = userManager.observe(\.currentUserIdentifier, options: [.new, .old, .initial]) { [weak self] manager, change in + guard let self = self else { return } + + let newProfileId = change.newValue ?? nil + let oldProfileId = change.oldValue ?? nil + + print("[TvUserProfile] KVO fired - old: \(oldProfileId ?? "nil"), new: \(newProfileId ?? "nil")") + + // Only send event if the profile actually changed + if newProfileId != oldProfileId { + print("[TvUserProfile] Profile changed from \(oldProfileId ?? "nil") to \(newProfileId ?? "nil")") + self.sendEvent("onProfileChange", [ + "profileId": newProfileId as Any + ]) + } + } + + print("[TvUserProfile] Profile observer set up successfully") + } + #endif +} diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.js new file mode 100644 index 00000000..69d7880f --- /dev/null +++ b/plugins/withTVUserManagement.js @@ -0,0 +1,21 @@ +const { withEntitlementsPlist } = require("expo/config-plugins"); + +/** + * Expo config plugin to add User Management entitlement for tvOS profile linking + */ +const withTVUserManagement = (config) => { + return withEntitlementsPlist(config, (config) => { + // Only add for tvOS builds (check if building for TV) + // The entitlement is needed for TVUserManager.currentUserIdentifier to work + config.modResults["com.apple.developer.user-management"] = [ + "runs-as-current-user", + "get-current-user", + ]; + + console.log("[withTVUserManagement] Added user-management entitlement"); + + return config; + }); +}; + +module.exports = withTVUserManagement;