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
+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();