fix(remote): live auth-context refresh + lifecycle races

Keep a running companion-remote host's crypto identity in sync with the
active profile: subscribe to PlexHome/connection/join streams and rebuild
auth contexts (restarting the broadcast when they change) so a removed
home user or revoked borrowed connection stops controlling the host.

Serialize host start/stop/crypto-rebuild through a lifecycle lock, clean
up peer subscriptions before re-listening, and guard the replaced-client
onDone against clobbering the new client's session. Drop the context.mounted
guards that aborted an app-level host start, fix the DiscoveryView
init-flash/stuck-on-throw, and delete the dead initialize*Crypto helpers.
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent 2b7142bdcd
commit d193633a5d
6 changed files with 271 additions and 131 deletions
+18 -1
View File
@@ -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<ProfileSessionScreen> {
),
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<ConnectionRegistry>(),
activeProfile: context.read<ActiveProfileProvider>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
plexHome: context.read<PlexHomeService>(),
);
return provider;
},
),
],
child: _ProfileSessionNavigator(
isOfflineMode: widget.isOfflineMode,
+137 -97
View File
@@ -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<RemoteAuthContext> _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<StreamSubscription<void>> _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<void> _lifecycleLock = Future<void>.value();
int get reconnectAttempts => _reconnectAttempts;
StreamSubscription<RemoteCommand>? _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<bool> 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<bool> 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<void> _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<T> _serializeLifecycle<T>(Future<T> 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<bool> 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<bool> _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<void> _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<void> resetForLogout() async {
_reconnectTimer?.cancel();
_reconnectAttempts = 0;
_lastHostAddresses = null;
_lastHostClientId = null;
_lastAuthContextId = null;
await stopHostServer();
stopDiscovery();
_clearCryptoContext();
RemoteAuthService.instance.clearCache();
safeNotifyListeners();
Future<void> 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<String> get debugCryptoConnectionIds => _authContexts.map((context) => context.connectionId).toList();
Future<void> startHostServer() async {
Future<void> startHostServer() => _serializeLifecycle(_startHostServerLocked);
Future<void> _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<void> stopHostServer() async {
Future<void> stopHostServer() => _serializeLifecycle(_stopHostServerLocked);
Future<void> _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();
@@ -14,6 +14,9 @@ Future<bool> 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<ConnectionRegistry>();
final activeProfile = context.read<ActiveProfileProvider>();
final profileConnections = context.read<ProfileConnectionRegistry>();
@@ -23,9 +26,7 @@ Future<bool> 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<bool> 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;
@@ -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;
@@ -38,6 +38,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
String? _errorMessage;
bool _showManualEntry = false;
bool _cryptoReady = false;
bool _initializing = true;
late final CompanionRemoteProvider _provider;
StreamSubscription<List<DiscoveredHost>>? _discoverySubscription;
@@ -64,36 +65,47 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
}
Future<void> _initCryptoAndDiscover() async {
final connections = context.read<ConnectionRegistry>();
final activeProfile = context.read<ActiveProfileProvider>();
final profileConnections = context.read<ProfileConnectionRegistry>();
final plexHome = context.read<PlexHomeService>();
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<ConnectionRegistry>();
final activeProfile = context.read<ActiveProfileProvider>();
final profileConnections = context.read<ProfileConnectionRegistry>();
final plexHome = context.read<PlexHomeService>();
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<DiscoveryView> 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(
@@ -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);