276 lines
11 KiB
Dart
276 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connection/connection.dart';
|
|
import '../connection/connection_registry.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../screens/profile/pin_entry_dialog.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../services/system_shelf_service.dart';
|
|
import 'active_profile_binder.dart';
|
|
import 'active_profile_provider.dart';
|
|
import 'plex_home_switch.dart';
|
|
import 'profile.dart';
|
|
import 'profile_connection_registry.dart';
|
|
|
|
/// How a UI-driven activation attempt ended. `cancelled` (user backed out
|
|
/// of a PIN dialog) is not an error and must not surface a failure message.
|
|
enum ProfileActivationOutcome { activated, cancelled, failed }
|
|
|
|
class _ProfileActivationResult {
|
|
const _ProfileActivationResult(this.outcome, {this.rollbackProfile, this.activationGeneration});
|
|
|
|
final ProfileActivationOutcome outcome;
|
|
final Profile? rollbackProfile;
|
|
final int? activationGeneration;
|
|
}
|
|
|
|
/// Activate [profile] from a UI surface, prompting for the PIN when the
|
|
/// profile is protected. Loops on wrong-PIN entries until the user submits
|
|
/// the right PIN or backs out.
|
|
///
|
|
/// The retry loop uses the same shake-on-error pattern as Plex Home users
|
|
/// — see [showPinEntryDialog].
|
|
///
|
|
/// For [ProfileKind.plexHome] profiles whose `plexProtected` flag is set,
|
|
/// the PIN is validated up front via `/home/users/{uuid}/switch` so a
|
|
/// failed PIN never flips `_active`. The minted user-token is saved and
|
|
/// the profile is marked pre-verified on the binder, so it reuses the cached
|
|
/// token instead of re-prompting for the same PIN.
|
|
Future<_ProfileActivationResult> _activateProfileWithPin(BuildContext context, Profile profile) async {
|
|
if (profile.isPlexHome) {
|
|
if (profile.plexProtected) {
|
|
final verified = await _preVerifyPlexHomePin(context, profile);
|
|
if (!context.mounted) {
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
|
|
}
|
|
if (verified != PlexHomeSwitchStatus.success) {
|
|
return _ProfileActivationResult(
|
|
verified == PlexHomeSwitchStatus.cancelled
|
|
? ProfileActivationOutcome.cancelled
|
|
: ProfileActivationOutcome.failed,
|
|
);
|
|
}
|
|
}
|
|
return _activateVerifiedProfile(context, profile);
|
|
}
|
|
|
|
if (!profile.isPinProtected) {
|
|
return _activateVerifiedProfile(context, profile);
|
|
}
|
|
|
|
String? errorMessage;
|
|
while (true) {
|
|
if (!context.mounted) {
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
|
|
}
|
|
final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
|
|
if (!context.mounted) {
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
|
|
}
|
|
if (pin == null) {
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
|
|
}
|
|
final hash = profile.pinHash;
|
|
if (hash != null && verifyPin(pin, hash)) {
|
|
return _activateVerifiedProfile(context, profile, pin: pin);
|
|
}
|
|
errorMessage = t.profiles.incorrectPinTryAgain;
|
|
}
|
|
}
|
|
|
|
Future<_ProfileActivationResult> _activateVerifiedProfile(BuildContext context, Profile profile, {String? pin}) async {
|
|
final active = context.read<ActiveProfileProvider>();
|
|
final binder = context.read<ActiveProfileBinder>();
|
|
final shelf = SystemShelfService();
|
|
final oldOwner = active.activeId;
|
|
final requestGeneration = active.beginIdentityMutationRequest();
|
|
int? activationGeneration;
|
|
|
|
try {
|
|
if (oldOwner != null && oldOwner != profile.id) {
|
|
await shelf.endProfileSession(oldOwner);
|
|
if (!active.isIdentityMutationRequestCurrent(requestGeneration)) {
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
|
|
}
|
|
}
|
|
|
|
// Capture the rollback owner only once this verified request has been
|
|
// admitted. Protected-profile verification may have awaited while another
|
|
// profile became authoritative, so anything captured by the UI caller is
|
|
// stale by this point.
|
|
final rollbackProfile = active.active;
|
|
binder.markUserInitiatedActivation(profile.id);
|
|
final activation = active.activate(profile, pin: pin);
|
|
activationGeneration = active.identityMutationGeneration;
|
|
final activated = await activation;
|
|
if (activated) {
|
|
return _ProfileActivationResult(
|
|
ProfileActivationOutcome.activated,
|
|
rollbackProfile: rollbackProfile,
|
|
activationGeneration: activationGeneration,
|
|
);
|
|
}
|
|
} catch (error, stackTrace) {
|
|
final stillCurrent = activationGeneration == null
|
|
? active.isIdentityMutationRequestCurrent(requestGeneration)
|
|
: active.identityMutationGeneration == activationGeneration;
|
|
if (stillCurrent) {
|
|
appLogger.w('Failed to activate profile ${profile.id}', error: error, stackTrace: stackTrace);
|
|
}
|
|
} finally {
|
|
active.finishIdentityMutationRequest(requestGeneration);
|
|
}
|
|
|
|
final stillCurrent = activationGeneration == null
|
|
? active.isIdentityMutationRequestCurrent(requestGeneration)
|
|
: active.identityMutationGeneration == activationGeneration;
|
|
if (!stillCurrent) {
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
|
|
}
|
|
|
|
// Activation may fail or throw while the prior identity is still
|
|
// authoritative. Admit it again, but never replay rows captured before the
|
|
// failed switch.
|
|
if (oldOwner != null && active.activeId == oldOwner) {
|
|
shelf.beginProfileSession(oldOwner);
|
|
}
|
|
return const _ProfileActivationResult(ProfileActivationOutcome.failed);
|
|
}
|
|
|
|
/// Activate [profile] from a UI surface, then wait until the active profile's
|
|
/// server/token binding has settled. Shows the standard switch failure message
|
|
/// for activation and binding failures — but not for a PIN-dialog cancel,
|
|
/// which is the user changing their mind, not an error.
|
|
Future<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
|
|
final activeProvider = context.read<ActiveProfileProvider>();
|
|
final binder = context.read<ActiveProfileBinder>();
|
|
final shelf = SystemShelfService();
|
|
final activation = await _activateProfileWithPin(context, profile);
|
|
if (!context.mounted) return false;
|
|
switch (activation.outcome) {
|
|
case ProfileActivationOutcome.cancelled:
|
|
return false;
|
|
case ProfileActivationOutcome.failed:
|
|
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName));
|
|
return false;
|
|
case ProfileActivationOutcome.activated:
|
|
break;
|
|
}
|
|
|
|
final previousProfile = activation.rollbackProfile;
|
|
var activationGeneration = activation.activationGeneration;
|
|
if (activationGeneration == null) return false;
|
|
bool isCurrentActivation(String expectedProfileId) =>
|
|
activeProvider.committedIdentityGeneration == activationGeneration &&
|
|
activeProvider.activeId == expectedProfileId;
|
|
|
|
if (!isCurrentActivation(profile.id)) return false;
|
|
final bound = await activeProvider.awaitBindingSettle();
|
|
if (!isCurrentActivation(profile.id)) return false;
|
|
if (bound) return true;
|
|
|
|
if (previousProfile != null && previousProfile.id != profile.id && isCurrentActivation(profile.id)) {
|
|
final rollbackRequestGeneration = activeProvider.beginIdentityMutationRequest();
|
|
try {
|
|
await shelf.endProfileSession(profile.id);
|
|
if (!isCurrentActivation(profile.id)) return false;
|
|
|
|
final rollbackGeneration = await activeProvider.restoreAfterFailedActivation(
|
|
previousProfile,
|
|
expectedActiveId: profile.id,
|
|
expectedCommittedGeneration: activationGeneration,
|
|
requestGeneration: rollbackRequestGeneration,
|
|
);
|
|
if (rollbackGeneration == null) return false;
|
|
activationGeneration = rollbackGeneration;
|
|
if (!isCurrentActivation(previousProfile.id)) return false;
|
|
|
|
binder.markUserInitiatedActivation(previousProfile.id);
|
|
final rebind = binder.rebindActive();
|
|
final restored = await activeProvider.awaitBindingSettle();
|
|
if (!isCurrentActivation(previousProfile.id)) {
|
|
await rebind;
|
|
return false;
|
|
}
|
|
await rebind;
|
|
if (!isCurrentActivation(previousProfile.id)) return false;
|
|
|
|
if (restored) {
|
|
shelf.beginProfileSession(previousProfile.id);
|
|
}
|
|
} catch (error, stackTrace) {
|
|
appLogger.w(
|
|
'Failed to restore ${previousProfile.id} after profile switch failure',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
} finally {
|
|
activeProvider.finishIdentityMutationRequest(rollbackRequestGeneration);
|
|
}
|
|
}
|
|
if (context.mounted && activeProvider.committedIdentityGeneration == activationGeneration) {
|
|
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On
|
|
/// success, persist the minted user-token and mark the profile as
|
|
/// pre-verified so [ActiveProfileBinder] reuses the cached token instead
|
|
/// of re-prompting.
|
|
///
|
|
/// Returns [PlexHomeSwitchStatus.success] without doing anything when the
|
|
/// profile lacks the parent/uuid metadata or the parent connection is
|
|
/// missing — the binder's existing missing-metadata path will fire and
|
|
/// silently produce an empty bind, matching today's behavior. We don't
|
|
/// want to fail activation outright for users in unusual data states.
|
|
Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile profile) async {
|
|
final parentId = profile.parentConnectionId;
|
|
final homeUuid = profile.plexHomeUserUuid;
|
|
if (parentId == null || homeUuid == null) return PlexHomeSwitchStatus.success;
|
|
|
|
final connections = context.read<ConnectionRegistry>();
|
|
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
|
final binder = context.read<ActiveProfileBinder>();
|
|
final all = await connections.list();
|
|
PlexAccountConnection? account;
|
|
for (final c in all) {
|
|
if (c.id == parentId && c is PlexAccountConnection) {
|
|
account = c;
|
|
break;
|
|
}
|
|
}
|
|
if (account == null) return PlexHomeSwitchStatus.success;
|
|
|
|
final result = await mintPlexHomeUserToken(
|
|
account: account,
|
|
homeUserUuid: homeUuid,
|
|
requiresPin: true,
|
|
promptForPin: ({String? errorMessage}) async {
|
|
if (!context.mounted) return null;
|
|
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
|
|
},
|
|
persistTo: pcRegistry,
|
|
persistProfileId: profile.id,
|
|
logLabel: profile.displayName,
|
|
);
|
|
if (result.succeeded) binder.markPlexHomePreVerified(profile.id);
|
|
return result.status;
|
|
}
|
|
|
|
/// Verify [pin] against [profile]'s stored PIN hash *without* activating it.
|
|
/// Used by the borrow flow: we need to confirm the user knows the source
|
|
/// profile's PIN before letting them copy a connection out of it.
|
|
///
|
|
/// Plex Home profiles can't be verified locally — their PIN lives on Plex's
|
|
/// servers. Callers should fall through to a real `/home/users/.../switch`
|
|
/// call instead.
|
|
bool verifyProfilePin(Profile profile, String pin) {
|
|
if (!profile.isLocal) return false;
|
|
final hash = profile.pinHash;
|
|
if (hash == null || hash.isEmpty) return true;
|
|
return verifyPin(pin, hash);
|
|
}
|