diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index d7754462..74d20c5e 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -3,10 +3,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../connection/connection_registry.dart'; import '../focus/key_event_utils.dart'; import '../media/ids.dart'; import '../media/media_server_client.dart'; import '../profiles/active_profile_provider.dart'; +import '../profiles/plex_home_service.dart'; +import '../profiles/profile_connection_registry.dart'; import '../providers/companion_remote_provider.dart'; import '../providers/discover_provider.dart'; import '../providers/hidden_libraries_provider.dart'; @@ -159,7 +162,21 @@ class _ProfileSessionScreenState extends State { ), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), - ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()), + ChangeNotifierProvider( + create: (context) { + final provider = CompanionRemoteProvider(); + // Keep a running host's crypto identity live: a home user + // removed or a borrowed connection revoked mid-session must + // stop controlling the broadcast. + provider.bindProfileServices( + connections: context.read(), + activeProfile: context.read(), + profileConnections: context.read(), + plexHome: context.read(), + ); + return provider; + }, + ), ], child: _ProfileSessionNavigator( isOfflineMode: widget.isOfflineMode, diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index a8b8e679..b080eb65 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -12,6 +12,7 @@ import '../models/companion_remote/remote_session.dart'; import '../models/plex/plex_home.dart'; import '../profiles/active_plex_identity.dart'; import '../profiles/active_profile_provider.dart'; +import '../profiles/plex_home_service.dart'; import '../profiles/profile.dart'; import '../profiles/profile_connection_registry.dart'; import '../services/companion_remote/companion_remote_peer_service.dart'; @@ -50,6 +51,21 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin List _authContexts = const []; String? _cryptoProfileId; + // Profile-scoped services watched so a running host's crypto identity tracks + // home-user / connection changes (a removed home user or revoked borrowed + // connection must stop controlling an already-broadcasting host). + ConnectionRegistry? _boundConnections; + ActiveProfileProvider? _boundActiveProfile; + ProfileConnectionRegistry? _boundProfileConnections; + PlexHomeService? _boundPlexHome; + final List> _profileServiceSubs = []; + bool _authRefreshScheduled = false; + + // Serializes host start/stop/crypto-rebuild so overlapping lifecycle calls + // (a user action and a live auth-context refresh) can't interleave and + // corrupt the peer service. + Future _lifecycleLock = Future.value(); + int get reconnectAttempts => _reconnectAttempts; StreamSubscription? _commandSubscription; @@ -109,101 +125,86 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin safeNotifyListeners(); } - /// Initialize crypto context from Plex home data plus the active profile's - /// connection. The clientIdentifier is the parent Plex account's - /// `clientIdentifier` (used as the LAN device id) and the userUUID is the - /// active home user's uuid (used to scope per-user LAN traffic). - Future initializeCrypto({ - required PlexHome? home, - required PlexAccountConnection? account, - required Profile? activeProfile, - String? activeUserUuid, - }) async { - if (home == null || home.adminUser == null) { - appLogger.w('CompanionRemote: Cannot init crypto — no home data'); - return false; - } - if (account == null) { - appLogger.w('CompanionRemote: Cannot init crypto — no Plex account'); - return false; - } + /// Bind the profile-scoped services whose changes must keep a running host's + /// crypto identity current: a removed home user or revoked borrowed + /// connection has to stop controlling an already-broadcasting host. Wired + /// once when the provider is created; a second call is a no-op. + void bindProfileServices({ + required ConnectionRegistry connections, + required ActiveProfileProvider activeProfile, + required ProfileConnectionRegistry profileConnections, + required PlexHomeService plexHome, + }) { + if (_boundActiveProfile != null) return; + _boundConnections = connections; + _boundActiveProfile = activeProfile; + _boundProfileConnections = profileConnections; + _boundPlexHome = plexHome; - try { - final auth = RemoteAuthService.instance; - final homeSecret = await auth.deriveHomeSecretFromHome(home); - final discoveryKey = await auth.deriveDiscoveryKey(homeSecret); - final userUuid = activeUserUuid ?? activeProfile?.plexHomeUserUuid ?? home.adminUser!.uuid; - final allowedUserUuids = { - for (final user in home.users) - if (user.uuid.isNotEmpty) user.uuid, - if (userUuid.isNotEmpty) userUuid, - }.toList(); - _authContexts = [ - RemoteAuthContext( - id: auth.computeAuthContextId(homeSecret), - backend: 'plex', - connectionId: account.id, - homeSecret: homeSecret, - discoveryKey: discoveryKey, - clientIdentifier: account.clientIdentifier.isNotEmpty ? account.clientIdentifier : account.id, - userUuid: userUuid, - allowedUserUuids: allowedUserUuids, - ), - ]; - _cryptoProfileId = activeProfile?.id; - - appLogger.d('CompanionRemote: Crypto context initialized'); - return true; - } catch (e) { - appLogger.e('CompanionRemote: Failed to init crypto', error: e); - return false; - } + _profileServiceSubs.add(plexHome.stream.listen((_) => _scheduleAuthContextRefresh())); + _profileServiceSubs.add(connections.watchConnections().listen((_) => _scheduleAuthContextRefresh())); + _profileServiceSubs.add(profileConnections.watchAll().listen((_) => _scheduleAuthContextRefresh())); + activeProfile.addListener(_scheduleAuthContextRefresh); } - Future initializeJellyfinCrypto({ - required JellyfinConnection connection, - required Profile? activeProfile, - }) async { - if (connection.accessToken.isEmpty || connection.userId.isEmpty || connection.serverMachineId.isEmpty) { - appLogger.w('CompanionRemote: Cannot init Jellyfin crypto — incomplete connection'); - return false; - } + /// Coalesce a burst of stream events into a single rebuild. Only a running + /// host needs live identity updates — discovery/remote sessions resolve + /// crypto at connect time. + void _scheduleAuthContextRefresh() { + if (!isHostServerRunning) return; + if (_authRefreshScheduled) return; + _authRefreshScheduled = true; + scheduleMicrotask(() { + _authRefreshScheduled = false; + unawaited(_refreshHostAuthContexts()); + }); + } - try { - final auth = RemoteAuthService.instance; - final homeSecret = await auth.deriveJellyfinSecret( - serverMachineId: connection.serverMachineId, - userId: connection.userId, + Future _refreshHostAuthContexts() async { + final connections = _boundConnections; + final activeProfile = _boundActiveProfile; + final profileConnections = _boundProfileConnections; + final plexHome = _boundPlexHome; + if (connections == null || activeProfile == null || profileConnections == null || plexHome == null) { + return; + } + if (!isHostServerRunning) return; + + await _serializeLifecycle(() async { + if (!isHostServerRunning) return; + final ok = await _ensureCryptoReadyLocked( + null, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, ); - final discoveryKey = await auth.deriveDiscoveryKey(homeSecret); - _authContexts = [ - RemoteAuthContext( - id: auth.computeAuthContextId(homeSecret), - backend: 'jellyfin', - connectionId: connection.id, - homeSecret: homeSecret, - discoveryKey: discoveryKey, - clientIdentifier: connection.deviceId.isNotEmpty ? connection.deviceId : connection.id, - userUuid: connection.userId, - allowedUserUuids: [connection.userId], - ), - ]; - _cryptoProfileId = activeProfile?.id; + // Unchanged identities leave the host running (no restart). When they + // change, the rebuild tore the host down — bring it back so the new set + // is what's broadcasting. When every identity is gone the host stays + // down by design (`ok` is false). + if (ok && !isHostServerRunning) { + await _startHostServerLocked(); + } + }); + } - appLogger.d('CompanionRemote: Jellyfin crypto context initialized'); - return true; - } catch (e) { - appLogger.e('CompanionRemote: Failed to init Jellyfin crypto', error: e); - return false; - } + /// Run [action] after every previously-queued lifecycle action settles, so + /// start/stop/crypto-rebuild never overlap. The chain survives a throwing + /// action (errors surface to that action's caller, not the next in line). + Future _serializeLifecycle(Future Function() action) { + final result = _lifecycleLock.then((_) => action()); + _lifecycleLock = result.then((_) {}, onError: (_) {}); + return result; } RemoteAuthContext? get _primaryAuthContext => _authContexts.isEmpty ? null : _authContexts.first; bool get isCryptoReady => _authContexts.isNotEmpty; - /// Convenience: ensure crypto is initialized for every remote identity - /// attached to the active profile. + /// Ensure crypto is initialized for every remote identity attached to the + /// active profile. Serialized against host start/stop so a live refresh and + /// a user-driven start can't interleave. /// Returns true if crypto is ready (already initialized or just initialized). Future ensureCryptoReady( PlexHome? home, { @@ -213,6 +214,28 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin ActivePlexIdentity? identity, PlexAccountConnection? account, PlexHomeResolver? plexHomeForConnection, + }) { + return _serializeLifecycle( + () => _ensureCryptoReadyLocked( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + account: account, + plexHomeForConnection: plexHomeForConnection, + ), + ); + } + + Future _ensureCryptoReadyLocked( + PlexHome? home, { + required ConnectionRegistry connections, + required ActiveProfileProvider activeProfile, + required ProfileConnectionRegistry profileConnections, + ActivePlexIdentity? identity, + PlexAccountConnection? account, + PlexHomeResolver? plexHomeForConnection, }) async { await activeProfile.initialize(); final profile = activeProfile.active; @@ -404,8 +427,10 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } Future _prepareForCryptoRebuild() async { + // Always invoked from inside the lifecycle lock — use the unlocked stop so + // we don't deadlock on our own chain. if (isInSession || isHostServerRunning) { - await stopHostServer(); + await _stopHostServerLocked(); } else { stopDiscovery(); _cleanupSubscriptions(); @@ -421,17 +446,19 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin /// Fully tear down network/session state and forget derived crypto material. /// Used by logout so an app-level provider surviving route replacement does /// not keep broadcasting with the previous Plex Home identity. - Future resetForLogout() async { - _reconnectTimer?.cancel(); - _reconnectAttempts = 0; - _lastHostAddresses = null; - _lastHostClientId = null; - _lastAuthContextId = null; - await stopHostServer(); - stopDiscovery(); - _clearCryptoContext(); - RemoteAuthService.instance.clearCache(); - safeNotifyListeners(); + Future resetForLogout() { + return _serializeLifecycle(() async { + _reconnectTimer?.cancel(); + _reconnectAttempts = 0; + _lastHostAddresses = null; + _lastHostClientId = null; + _lastAuthContextId = null; + await _stopHostServerLocked(); + stopDiscovery(); + _clearCryptoContext(); + RemoteAuthService.instance.clearCache(); + safeNotifyListeners(); + }); } @visibleForTesting @@ -446,7 +473,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin @visibleForTesting List get debugCryptoConnectionIds => _authContexts.map((context) => context.connectionId).toList(); - Future startHostServer() async { + Future startHostServer() => _serializeLifecycle(_startHostServerLocked); + + Future _startHostServerLocked() async { if (_peerService?.isServerRunning == true) return; if (!isCryptoReady) { appLogger.w('CompanionRemote: Cannot start host — crypto not initialized'); @@ -494,7 +523,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } /// Stop the host server and LAN broadcasting. - Future stopHostServer() async { + Future stopHostServer() => _serializeLifecycle(_stopHostServerLocked); + + Future _stopHostServerLocked() async { _intentionalDisconnect = true; await _discoveryService?.stopBroadcasting(); @@ -617,6 +648,10 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } void _setupPeerServiceListeners() { + // A rebuild/reconnect can re-enter here with live subscriptions from the + // previous peer service still attached; drop them first so events don't + // fan out to a stale service. + _cleanupSubscriptions(); _commandSubscription = _peerService!.onCommandReceived.listen( (command) { appLogger.d('CompanionRemote: Command received: ${command.type}'); @@ -825,6 +860,11 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin @override void dispose() { _reconnectTimer?.cancel(); + _boundActiveProfile?.removeListener(_scheduleAuthContextRefresh); + for (final sub in _profileServiceSubs) { + sub.cancel(); + } + _profileServiceSubs.clear(); _discoveryService?.dispose(); _peerService?.dispose(); RemoteAuthService.instance.clearCache(); diff --git a/lib/services/companion_remote/companion_remote_host_controller.dart b/lib/services/companion_remote/companion_remote_host_controller.dart index 9c03f84d..822f592e 100644 --- a/lib/services/companion_remote/companion_remote_host_controller.dart +++ b/lib/services/companion_remote/companion_remote_host_controller.dart @@ -14,6 +14,9 @@ Future startCompanionRemoteHost(BuildContext context) async { if (companionRemote.isHostServerRunning) return true; try { + // The host is an app-level service, not bound to this widget: everything + // it needs is captured up front, so an unmount mid-await must not abort a + // start the user asked for. Hence no `context.mounted` guards below. final connections = context.read(); final activeProfile = context.read(); final profileConnections = context.read(); @@ -23,9 +26,7 @@ Future startCompanionRemoteHost(BuildContext context) async { connections: connections, profileConnections: profileConnections, ); - if (!context.mounted) return false; final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); - if (!context.mounted) return false; final ok = await companionRemote.ensureCryptoReady( home, connections: connections, @@ -34,7 +35,7 @@ Future startCompanionRemoteHost(BuildContext context) async { identity: identity, plexHomeForConnection: plexHome.materializePlexHomeForConnection, ); - if (!context.mounted || !ok) return false; + if (!ok) return false; await companionRemote.startHostServer(); return companionRemote.isHostServerRunning; diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index 806193e9..5a9cfd9c 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -388,7 +388,10 @@ class CompanionRemotePeerService with KeepaliveMixin { onDone: () { authTimeout?.cancel(); appLogger.d('CompanionRemote: WebSocket connection closed'); - if (isAuthenticated) { + // A replaced client's socket closes AFTER the new client already took + // over `_clientSocket`; only the socket that still owns the session may + // tear it down, or we'd clobber the live connection. + if (isAuthenticated && identical(_clientSocket, socket)) { _clientSocket = null; _sessionEncKey = null; _isAuthenticated = false; diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 5ff21b95..2a7169c9 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -38,6 +38,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi String? _errorMessage; bool _showManualEntry = false; bool _cryptoReady = false; + bool _initializing = true; late final CompanionRemoteProvider _provider; StreamSubscription>? _discoverySubscription; @@ -64,36 +65,47 @@ class _DiscoveryViewState extends State with ControllerDisposerMi } Future _initCryptoAndDiscover() async { - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - if (!mounted) return; - final home = await _resolveHome(identity?.account.id); - if (!mounted) return; - await _provider.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); + try { + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + if (!mounted) return; + final home = await _resolveHome(identity?.account.id); + if (!mounted) return; + await _provider.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); + } catch (e) { + appLogger.e('CompanionRemote: crypto init failed', error: e); + } if (!mounted) return; + // The "crypto init failed" card only surfaces once init has actually + // finished — while it's running the section shows a loading state so we + // don't flash an error at open, and a thrown init still resolves to a + // stable (non-stuck) failure card. if (_provider.isCryptoReady) { - setState(() => _cryptoReady = true); + setState(() { + _cryptoReady = true; + _initializing = false; + }); _startDiscovery(); } else { setState(() { _cryptoReady = false; + _initializing = false; _isSearching = false; - _errorMessage = t.companionRemote.pairing.cryptoInitFailed; }); } } @@ -245,6 +257,21 @@ class _DiscoveryViewState extends State with ControllerDisposerMi } Widget _buildDiscoverySection() { + if (_initializing) { + return Card( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + const SizedBox(width: 32, height: 32, child: CircularProgressIndicator(strokeWidth: 3)), + const SizedBox(height: 16), + Text(t.companionRemote.pairing.searchingForDevices, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ); + } + if (!_cryptoReady) { return Card( child: Padding( diff --git a/test/providers/companion_remote_provider_test.dart b/test/providers/companion_remote_provider_test.dart index c346087e..ce2b70d5 100644 --- a/test/providers/companion_remote_provider_test.dart +++ b/test/providers/companion_remote_provider_test.dart @@ -55,7 +55,7 @@ void main() { p.dispose(); }); - test('isCryptoReady is false until initializeCrypto is called', () { + test('isCryptoReady is false until ensureCryptoReady succeeds', () { final p = CompanionRemoteProvider(); expect(p.isCryptoReady, isFalse); p.dispose(); @@ -186,21 +186,36 @@ void main() { final accountB = _plexAccount('plex-b', 'client-b'); final profileA = _localProfile('profile-a'); final profileB = _localProfile('profile-b'); + await connections.upsert(accountA); await connections.upsert(accountB); + await profiles.upsert(profileA); await profiles.upsert(profileB); + await profileConnections.upsert( + ProfileConnection(profileId: profileA.id, connectionId: accountA.id, userIdentifier: 'admin-a'), + makeDefault: true, + ); await profileConnections.upsert( ProfileConnection(profileId: profileB.id, connectionId: accountB.id, userIdentifier: 'admin-b'), makeDefault: true, ); - await storage.setActiveProfileId(profileB.id); + await storage.setActiveProfileId(profileA.id); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); - await provider.initializeCrypto(home: _home('admin-a'), account: accountA, activeProfile: profileA); + final okA = await provider.ensureCryptoReady( + _home('admin-a'), + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + account: accountA, + ); + expect(okA, isTrue); expect(provider.debugCryptoConnectionId, accountA.id); expect(provider.debugCryptoProfileId, profileA.id); + await active.activate(profileB); + final ok = await provider.ensureCryptoReady( _home('admin-b'), connections: connections, @@ -418,12 +433,49 @@ void main() { }); test('resetForLogout clears crypto context', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final account = _plexAccount('plex-a', 'client-a'); + final profile = _localProfile('profile-a'); + await connections.upsert(account); + await profiles.upsert(profile); + await profileConnections.upsert( + ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'admin-a'), + makeDefault: true, + ); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); - await provider.initializeCrypto( - home: _home('admin-a'), - account: _plexAccount('plex-a', 'client-a'), - activeProfile: _localProfile('profile-a'), + await provider.ensureCryptoReady( + _home('admin-a'), + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + account: account, ); expect(provider.isCryptoReady, isTrue);