refactor(tv): simplify user profile management with automatic sandboxing

This commit is contained in:
Fredrik Burmester
2026-01-31 17:28:15 +01:00
parent 717186e13e
commit 44b7434cdd
8 changed files with 281 additions and 7 deletions

View File

@@ -76,6 +76,7 @@
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{

View File

@@ -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";

View File

@@ -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);
}
}
};

View File

@@ -0,0 +1,8 @@
{
"name": "tv-user-profile",
"version": "1.0.0",
"platforms": ["apple"],
"apple": {
"modules": ["TvUserProfileModule"]
}
}

View File

@@ -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<boolean>;
addListener<K extends keyof TvUserProfileModuleEvents>(
eventName: K,
listener: TvUserProfileModuleEvents[K],
): EventSubscription;
}
// Only load the native module on Apple platforms
const TvUserProfileModule: TvUserProfileModuleType | null =
Platform.OS === "ios"
? requireNativeModule<TvUserProfileModuleType>("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<boolean> {
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,
};

View File

@@ -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

View File

@@ -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
}

View File

@@ -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;