refactor(profiles): centralize Plex token policy
This commit is contained in:
@@ -41,6 +41,15 @@ class _ProfileBindResult {
|
|||||||
/// surfacing as an unhandled async error.
|
/// surfacing as an unhandled async error.
|
||||||
typedef _FetchOutcome = ({List<PlexServer>? servers, Object? error, StackTrace? stackTrace});
|
typedef _FetchOutcome = ({List<PlexServer>? servers, Object? error, StackTrace? stackTrace});
|
||||||
|
|
||||||
|
enum _ServerFetchStatus { success, empty, authRejected, transientFailure, cancelled, failure }
|
||||||
|
|
||||||
|
typedef _ClassifiedFetch = ({
|
||||||
|
_ServerFetchStatus status,
|
||||||
|
List<PlexServer> servers,
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
});
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce}) {
|
bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce}) {
|
||||||
return preVerified || !hasBoundOnce;
|
return preVerified || !hasBoundOnce;
|
||||||
@@ -88,6 +97,7 @@ class ActiveProfileBinder {
|
|||||||
// only loops when the active id has drifted — this flag covers same-id
|
// only loops when the active id has drifted — this flag covers same-id
|
||||||
// re-runs, e.g. after a borrow upserts a new join row.
|
// re-runs, e.g. after a borrow upserts a new join row.
|
||||||
bool _pendingSameIdRebind = false;
|
bool _pendingSameIdRebind = false;
|
||||||
|
int _bindGeneration = 0;
|
||||||
|
|
||||||
/// True after the binder has successfully bound at least one profile in
|
/// True after the binder has successfully bound at least one profile in
|
||||||
/// this session. Once set, subsequent rebinds bypass the user-token
|
/// this session. Once set, subsequent rebinds bypass the user-token
|
||||||
@@ -237,6 +247,7 @@ class ActiveProfileBinder {
|
|||||||
|
|
||||||
Future<bool> _runRebindOnce() async {
|
Future<bool> _runRebindOnce() async {
|
||||||
_bindingProfileId = activeProfile.activeId;
|
_bindingProfileId = activeProfile.activeId;
|
||||||
|
final generation = ++_bindGeneration;
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
var success = false;
|
var success = false;
|
||||||
String? attemptedProfileId;
|
String? attemptedProfileId;
|
||||||
@@ -270,6 +281,7 @@ class ActiveProfileBinder {
|
|||||||
// registry read pays per-row CredentialVault reveals).
|
// registry read pays per-row CredentialVault reveals).
|
||||||
final joinRows = await profileConnections.listForProfile(profile.id);
|
final joinRows = await profileConnections.listForProfile(profile.id);
|
||||||
final connectionsById = {for (final c in await connections.list()) c.id: c};
|
final connectionsById = {for (final c in await connections.list()) c.id: c};
|
||||||
|
if (!_isCurrentBind(profile.id, generation)) return false;
|
||||||
|
|
||||||
// PIN prompts may only surface from a user-initiated bind or the
|
// PIN prompts may only surface from a user-initiated bind or the
|
||||||
// session's initial bind (cold-start resume). Passive rebinds — an
|
// session's initial bind (cold-start resume). Passive rebinds — an
|
||||||
@@ -290,14 +302,27 @@ class ActiveProfileBinder {
|
|||||||
// on top of an otherwise reachable Jellyfin or borrowed-server bind.
|
// on top of an otherwise reachable Jellyfin or borrowed-server bind.
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
if (profile.isPlexHome)
|
if (profile.isPlexHome)
|
||||||
_bindPlexHome(profile, joinRows: joinRows, connectionsById: connectionsById, allowPinPrompt: allowPinPrompt),
|
_bindPlexHome(
|
||||||
|
profile,
|
||||||
|
joinRows: joinRows,
|
||||||
|
connectionsById: connectionsById,
|
||||||
|
allowPinPrompt: allowPinPrompt,
|
||||||
|
generation: generation,
|
||||||
|
),
|
||||||
// Both kinds also bind borrowed/extra connections via the join table.
|
// Both kinds also bind borrowed/extra connections via the join table.
|
||||||
// For plex_home this handles a Jellyfin server (or extra Plex account)
|
// For plex_home this handles a Jellyfin server (or extra Plex account)
|
||||||
// that was attached to the profile via the borrow flow — the parent
|
// that was attached to the profile via the borrow flow — the parent
|
||||||
// account is bound by `_bindPlexHome` above and isn't represented in
|
// account is bound by `_bindPlexHome` above and isn't represented in
|
||||||
// the join table.
|
// the join table.
|
||||||
_bindJoinRows(profile, joinRows: joinRows, connectionsById: connectionsById, allowPinPrompt: allowPinPrompt),
|
_bindJoinRows(
|
||||||
|
profile,
|
||||||
|
joinRows: joinRows,
|
||||||
|
connectionsById: connectionsById,
|
||||||
|
allowPinPrompt: allowPinPrompt,
|
||||||
|
generation: generation,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
if (!_isCurrentBind(profile.id, generation)) return false;
|
||||||
final visibleServerIds = <String>{};
|
final visibleServerIds = <String>{};
|
||||||
for (final result in results) {
|
for (final result in results) {
|
||||||
visibleServerIds.addAll(result.visibleServerIds);
|
visibleServerIds.addAll(result.visibleServerIds);
|
||||||
@@ -374,6 +399,7 @@ class ActiveProfileBinder {
|
|||||||
required List<ProfileConnection> joinRows,
|
required List<ProfileConnection> joinRows,
|
||||||
required Map<String, Connection> connectionsById,
|
required Map<String, Connection> connectionsById,
|
||||||
required bool allowPinPrompt,
|
required bool allowPinPrompt,
|
||||||
|
required int generation,
|
||||||
}) async {
|
}) async {
|
||||||
final parentId = profile.parentConnectionId;
|
final parentId = profile.parentConnectionId;
|
||||||
final homeUuid = profile.plexHomeUserUuid;
|
final homeUuid = profile.plexHomeUserUuid;
|
||||||
@@ -390,6 +416,7 @@ class ActiveProfileBinder {
|
|||||||
return const _ProfileBindResult.empty();
|
return const _ProfileBindResult.empty();
|
||||||
}
|
}
|
||||||
final auth = await _ensureAuth();
|
final auth = await _ensureAuth();
|
||||||
|
if (!_isCurrentBind(profile.id, generation)) return const _ProfileBindResult.empty();
|
||||||
|
|
||||||
// Fast path: reuse the previously-minted user-token from the
|
// Fast path: reuse the previously-minted user-token from the
|
||||||
// [ProfileConnection] row for this profile's parent connection.
|
// [ProfileConnection] row for this profile's parent connection.
|
||||||
@@ -415,101 +442,54 @@ class ActiveProfileBinder {
|
|||||||
'ActiveProfileBinder: cache lookup for ${profile.displayName} (account=${account.id}, '
|
'ActiveProfileBinder: cache lookup for ${profile.displayName} (account=${account.id}, '
|
||||||
'uuid=$homeUuid, useCache=$useCache, preVerified=$preVerified): ${cachedToken == null ? (useCache ? "MISS" : "BYPASS") : "HIT"}',
|
'uuid=$homeUuid, useCache=$useCache, preVerified=$preVerified): ${cachedToken == null ? (useCache ? "MISS" : "BYPASS") : "HIT"}',
|
||||||
);
|
);
|
||||||
if (cachedToken != null) {
|
return _bindPlexWithTokenPolicy(
|
||||||
// Fire the resource refresh and the optimistic cached-metadata connect
|
|
||||||
// together: the plex.tv round-trip no longer gates server probing on
|
|
||||||
// cold start. The reconcile applies whatever the refresh learns
|
|
||||||
// (rotated tokens, changed URIs, membership) once it lands.
|
|
||||||
final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, cachedToken, profile.displayName));
|
|
||||||
final optimistic = await _bindOptimisticallyFromCache(
|
|
||||||
account: account,
|
|
||||||
userToken: cachedToken,
|
|
||||||
profileId: profile.id,
|
|
||||||
profileLabel: profile.displayName,
|
|
||||||
fetchOutcome: fetchOutcome,
|
|
||||||
onAuthRejected: () => profileConnections.recordToken(profile.id, parentId, ''),
|
|
||||||
);
|
|
||||||
if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) {
|
|
||||||
return optimistic;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final servers = await _unwrapServerFetch(fetchOutcome);
|
|
||||||
if (servers.isNotEmpty) {
|
|
||||||
appLogger.i('ActiveProfileBinder: using cached token for ${profile.displayName} (${servers.length} servers)');
|
|
||||||
unawaited(_persistRefreshedServers(account, servers));
|
|
||||||
return _connectFromServers(account, cachedToken, servers, profile.displayName);
|
|
||||||
}
|
|
||||||
appLogger.w(
|
|
||||||
'ActiveProfileBinder: cached token returned 0 servers for ${profile.displayName} — wiping and re-minting',
|
|
||||||
);
|
|
||||||
await profileConnections.recordToken(profile.id, parentId, '');
|
|
||||||
} on MediaServerHttpException catch (e) {
|
|
||||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
|
||||||
appLogger.w(
|
|
||||||
'ActiveProfileBinder: cached token rejected (${e.statusCode}) for ${profile.displayName} — falling back to /switch',
|
|
||||||
);
|
|
||||||
await profileConnections.recordToken(profile.id, parentId, '');
|
|
||||||
} else {
|
|
||||||
appLogger.w(
|
|
||||||
'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}',
|
|
||||||
error: e,
|
|
||||||
);
|
|
||||||
if (e.isTransient) {
|
|
||||||
// The optimistic pass already probed the cached metadata —
|
|
||||||
// don't burn another race on the same endpoints.
|
|
||||||
if (optimistic != null) return optimistic;
|
|
||||||
return _connectFromCachedServers(account, cachedToken, profile.displayName, error: e);
|
|
||||||
}
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w(
|
|
||||||
'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowPin && profile.plexProtected) {
|
|
||||||
appLogger.i('ActiveProfileBinder: suppressing PIN-gated /switch for passive rebind of ${profile.displayName}');
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
appLogger.i('ActiveProfileBinder: minting fresh user-token via /switch for ${profile.displayName}');
|
|
||||||
final result = await switchPlexHomeUserWithPin(
|
|
||||||
auth: auth,
|
auth: auth,
|
||||||
accountToken: account.accountToken,
|
account: account,
|
||||||
homeUserUuid: homeUuid,
|
profileId: profile.id,
|
||||||
requiresPin: profile.plexProtected,
|
profileLabel: profile.displayName,
|
||||||
// Plex can demand a PIN (error 1041) even when we didn't expect one;
|
generation: generation,
|
||||||
// a passive rebind answers that demand with a cancel, not a dialog.
|
cachedToken: cachedToken,
|
||||||
promptForPin: allowPin
|
invalidateCachedToken: () => profileConnections.recordToken(profile.id, parentId, ''),
|
||||||
? ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage)
|
mintToken: () async {
|
||||||
: ({String? errorMessage}) async => null,
|
if (!allowPin && profile.plexProtected) {
|
||||||
logLabel: profile.displayName,
|
appLogger.i(
|
||||||
|
'ActiveProfileBinder: suppressing PIN-gated /switch for passive rebind of ${profile.displayName}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
appLogger.i('ActiveProfileBinder: minting fresh user-token via /switch for ${profile.displayName}');
|
||||||
|
final result = await switchPlexHomeUserWithPin(
|
||||||
|
auth: auth,
|
||||||
|
accountToken: account.accountToken,
|
||||||
|
homeUserUuid: homeUuid,
|
||||||
|
requiresPin: profile.plexProtected,
|
||||||
|
// Plex can demand a PIN (error 1041) even when we didn't expect one;
|
||||||
|
// a passive rebind answers that demand with a cancel, not a dialog.
|
||||||
|
promptForPin: allowPin
|
||||||
|
? ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage)
|
||||||
|
: ({String? errorMessage}) async => null,
|
||||||
|
logLabel: profile.displayName,
|
||||||
|
);
|
||||||
|
return result.succeeded ? result.userToken : null;
|
||||||
|
},
|
||||||
|
persistMintedToken: (token) async {
|
||||||
|
// Plex Home parents normally have no join row, so create one as the
|
||||||
|
// stable home for the freshly minted profile token.
|
||||||
|
await profileConnections.upsert(
|
||||||
|
ProfileConnection(
|
||||||
|
profileId: profile.id,
|
||||||
|
connectionId: parentId,
|
||||||
|
userToken: token,
|
||||||
|
userIdentifier: homeUuid,
|
||||||
|
tokenAcquiredAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
appLogger.i(
|
||||||
|
'ActiveProfileBinder: persisted user-token for ${profile.displayName} '
|
||||||
|
'(account=${account.id}, uuid=$homeUuid, tokenLen=${token.length})',
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!result.succeeded) return const _ProfileBindResult.empty();
|
|
||||||
// Persist the minted user-token onto the parent ProfileConnection
|
|
||||||
// row. Plex Home profiles don't normally have a join row for the
|
|
||||||
// parent (the borrow flow is for *other* connections layered onto
|
|
||||||
// the profile), so creating one here gives the token a stable home
|
|
||||||
// alongside the rest of the profile's tokens — same shape as the
|
|
||||||
// local-profile path that `_bindLocalPlexConnection` already uses.
|
|
||||||
await profileConnections.upsert(
|
|
||||||
ProfileConnection(
|
|
||||||
profileId: profile.id,
|
|
||||||
connectionId: parentId,
|
|
||||||
userToken: result.userToken,
|
|
||||||
userIdentifier: homeUuid,
|
|
||||||
tokenAcquiredAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
appLogger.i(
|
|
||||||
'ActiveProfileBinder: persisted user-token for ${profile.displayName} '
|
|
||||||
'(account=${account.id}, uuid=$homeUuid, tokenLen=${result.userToken!.length})',
|
|
||||||
);
|
|
||||||
return _connectPlexServers(account, result.userToken!, profile.displayName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind every [ProfileConnection] row for [profile]. Used by both kinds:
|
/// Bind every [ProfileConnection] row for [profile]. Used by both kinds:
|
||||||
@@ -524,6 +504,7 @@ class ActiveProfileBinder {
|
|||||||
required List<ProfileConnection> joinRows,
|
required List<ProfileConnection> joinRows,
|
||||||
required Map<String, Connection> connectionsById,
|
required Map<String, Connection> connectionsById,
|
||||||
required bool allowPinPrompt,
|
required bool allowPinPrompt,
|
||||||
|
required int generation,
|
||||||
}) async {
|
}) async {
|
||||||
if (joinRows.isEmpty) {
|
if (joinRows.isEmpty) {
|
||||||
if (profile.isLocal) {
|
if (profile.isLocal) {
|
||||||
@@ -546,10 +527,18 @@ class ActiveProfileBinder {
|
|||||||
switch (conn) {
|
switch (conn) {
|
||||||
case PlexAccountConnection():
|
case PlexAccountConnection():
|
||||||
expected.addAll(conn.servers.map((server) => server.clientIdentifier));
|
expected.addAll(conn.servers.map((server) => server.clientIdentifier));
|
||||||
futures.add(_bindLocalPlexConnection(profile: profile, conn: conn, pc: pc, allowPinPrompt: allowPinPrompt));
|
futures.add(
|
||||||
|
_bindLocalPlexConnection(
|
||||||
|
profile: profile,
|
||||||
|
conn: conn,
|
||||||
|
pc: pc,
|
||||||
|
allowPinPrompt: allowPinPrompt,
|
||||||
|
generation: generation,
|
||||||
|
),
|
||||||
|
);
|
||||||
case JellyfinConnection():
|
case JellyfinConnection():
|
||||||
expected.add(conn.serverMachineId);
|
expected.add(conn.serverMachineId);
|
||||||
futures.add(_bindJellyfin(conn));
|
futures.add(_bindJellyfin(conn, profileId: profile.id, generation: generation));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final results = await Future.wait(futures);
|
final results = await Future.wait(futures);
|
||||||
@@ -565,102 +554,23 @@ class ActiveProfileBinder {
|
|||||||
required PlexAccountConnection conn,
|
required PlexAccountConnection conn,
|
||||||
required ProfileConnection pc,
|
required ProfileConnection pc,
|
||||||
required bool allowPinPrompt,
|
required bool allowPinPrompt,
|
||||||
|
required int generation,
|
||||||
}) async {
|
}) async {
|
||||||
final auth = await _ensureAuth();
|
final auth = await _ensureAuth();
|
||||||
String? userToken = pc.userToken;
|
if (!_isCurrentBind(profile.id, generation)) return const _ProfileBindResult.empty();
|
||||||
List<PlexServer>? servers;
|
return _bindPlexWithTokenPolicy(
|
||||||
|
auth: auth,
|
||||||
if (userToken != null && userToken.isNotEmpty) {
|
account: conn,
|
||||||
final cachedUserToken = userToken;
|
profileId: profile.id,
|
||||||
// Same optimistic shape as the plex_home cached path: probe cached
|
profileLabel: profile.displayName,
|
||||||
// metadata while the resource refresh runs alongside.
|
generation: generation,
|
||||||
final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, cachedUserToken, profile.displayName));
|
cachedToken: pc.userToken,
|
||||||
final optimistic = await _bindOptimisticallyFromCache(
|
invalidateCachedToken: () => profileConnections.recordToken(profile.id, conn.id, ''),
|
||||||
account: conn,
|
mintToken: () =>
|
||||||
userToken: cachedUserToken,
|
_mintLocalPlexToken(auth: auth, profile: profile, conn: conn, pc: pc, allowPinPrompt: allowPinPrompt),
|
||||||
profileId: profile.id,
|
persistMintedToken: (token) => profileConnections.recordToken(profile.id, conn.id, token),
|
||||||
profileLabel: profile.displayName,
|
markUsed: () => profileConnections.markUsed(profile.id, conn.id),
|
||||||
fetchOutcome: fetchOutcome,
|
);
|
||||||
onAuthRejected: () => profileConnections.recordToken(profile.id, conn.id, ''),
|
|
||||||
);
|
|
||||||
if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) {
|
|
||||||
await profileConnections.markUsed(profile.id, conn.id);
|
|
||||||
return optimistic;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
servers = await _unwrapServerFetch(fetchOutcome);
|
|
||||||
if (servers.isEmpty) {
|
|
||||||
appLogger.w(
|
|
||||||
'ActiveProfileBinder: cached local Plex token returned 0 servers for ${profile.displayName} — re-minting',
|
|
||||||
);
|
|
||||||
await profileConnections.recordToken(profile.id, conn.id, '');
|
|
||||||
userToken = null;
|
|
||||||
}
|
|
||||||
} on MediaServerHttpException catch (e) {
|
|
||||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
|
||||||
appLogger.w(
|
|
||||||
'ActiveProfileBinder: cached local Plex token rejected (${e.statusCode}) for ${profile.displayName} — re-minting',
|
|
||||||
);
|
|
||||||
await profileConnections.recordToken(profile.id, conn.id, '');
|
|
||||||
userToken = null;
|
|
||||||
} else {
|
|
||||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e);
|
|
||||||
if (e.isTransient) {
|
|
||||||
// The optimistic pass already probed the cached metadata.
|
|
||||||
if (optimistic != null) return optimistic;
|
|
||||||
final ids = await _connectFromCachedServers(conn, cachedUserToken, profile.displayName, error: e);
|
|
||||||
if (ids.visibleServerIds.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st);
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userToken == null || userToken.isEmpty) {
|
|
||||||
if (pc.userIdentifier.isEmpty) {
|
|
||||||
appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier');
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
final minted = await _mintLocalPlexToken(
|
|
||||||
auth: auth,
|
|
||||||
profile: profile,
|
|
||||||
conn: conn,
|
|
||||||
pc: pc,
|
|
||||||
allowPinPrompt: allowPinPrompt,
|
|
||||||
);
|
|
||||||
if (minted == null) return const _ProfileBindResult.empty();
|
|
||||||
userToken = minted;
|
|
||||||
try {
|
|
||||||
servers = await _fetchServersTimed(auth, userToken, profile.displayName);
|
|
||||||
} on MediaServerHttpException catch (e) {
|
|
||||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e);
|
|
||||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
|
||||||
serverManager.markPlexConnectionAuthError(conn);
|
|
||||||
final ids = conn.servers.map((server) => server.clientIdentifier).toSet();
|
|
||||||
return _ProfileBindResult.visible(ids);
|
|
||||||
}
|
|
||||||
if (e.isTransient) {
|
|
||||||
final ids = await _connectFromCachedServers(conn, userToken, profile.displayName, error: e);
|
|
||||||
if (ids.visibleServerIds.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st);
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (servers != null && servers.isNotEmpty) {
|
|
||||||
unawaited(_persistRefreshedServers(conn, servers));
|
|
||||||
}
|
|
||||||
final ids = await _connectFromServers(conn, userToken, servers ?? const <PlexServer>[], profile.displayName);
|
|
||||||
await profileConnections.markUsed(profile.id, conn.id);
|
|
||||||
return ids;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _mintLocalPlexToken({
|
Future<String?> _mintLocalPlexToken({
|
||||||
@@ -670,6 +580,10 @@ class ActiveProfileBinder {
|
|||||||
required ProfileConnection pc,
|
required ProfileConnection pc,
|
||||||
required bool allowPinPrompt,
|
required bool allowPinPrompt,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (pc.userIdentifier.isEmpty) {
|
||||||
|
appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final result = await switchPlexHomeUserWithPin(
|
final result = await switchPlexHomeUserWithPin(
|
||||||
auth: auth,
|
auth: auth,
|
||||||
accountToken: conn.accountToken,
|
accountToken: conn.accountToken,
|
||||||
@@ -684,39 +598,137 @@ class ActiveProfileBinder {
|
|||||||
logLabel: profile.displayName,
|
logLabel: profile.displayName,
|
||||||
);
|
);
|
||||||
if (!result.succeeded) return null;
|
if (!result.succeeded) return null;
|
||||||
final userToken = result.userToken!;
|
return result.userToken;
|
||||||
await profileConnections.recordToken(profile.id, conn.id, userToken);
|
|
||||||
return userToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_ProfileBindResult> _connectPlexServers(
|
/// Apply the common Plex token policy while callers retain ownership of
|
||||||
PlexAccountConnection account,
|
/// backend-specific minting, persistence, and post-bind bookkeeping.
|
||||||
String userToken,
|
Future<_ProfileBindResult> _bindPlexWithTokenPolicy({
|
||||||
String profileLabel,
|
required PlexAuthService auth,
|
||||||
) async {
|
required PlexAccountConnection account,
|
||||||
final auth = await _ensureAuth();
|
required String profileId,
|
||||||
final List<PlexServer> servers;
|
required String profileLabel,
|
||||||
try {
|
required int generation,
|
||||||
servers = await _fetchServersTimed(auth, userToken, profileLabel);
|
required String? cachedToken,
|
||||||
} on MediaServerHttpException catch (e, st) {
|
required Future<void> Function() invalidateCachedToken,
|
||||||
appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st);
|
required Future<String?> Function() mintToken,
|
||||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
required Future<void> Function(String token) persistMintedToken,
|
||||||
serverManager.markPlexConnectionAuthError(account);
|
Future<void> Function()? markUsed,
|
||||||
final ids = account.servers.map((server) => server.clientIdentifier).toSet();
|
}) async {
|
||||||
return _ProfileBindResult.visible(ids);
|
var userToken = cachedToken;
|
||||||
|
var usingCachedToken = userToken != null && userToken.isNotEmpty;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
|
|
||||||
|
if (!usingCachedToken) {
|
||||||
|
userToken = await mintToken();
|
||||||
|
if (userToken == null || userToken.isEmpty || !_isCurrentBind(profileId, generation)) {
|
||||||
|
return const _ProfileBindResult.empty();
|
||||||
|
}
|
||||||
|
await persistMintedToken(userToken);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
}
|
}
|
||||||
if (e.isTransient) {
|
|
||||||
return _connectFromCachedServers(account, userToken, profileLabel, error: e, stackTrace: st);
|
final token = userToken!;
|
||||||
|
final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, token, profileLabel));
|
||||||
|
_ProfileBindResult? optimistic;
|
||||||
|
if (usingCachedToken) {
|
||||||
|
// Probe cached metadata while plex.tv refreshes resources. A live
|
||||||
|
// cached bind settles immediately and reconciles the fetch later.
|
||||||
|
optimistic = await _bindOptimisticallyFromCache(
|
||||||
|
account: account,
|
||||||
|
userToken: token,
|
||||||
|
profileId: profileId,
|
||||||
|
profileLabel: profileLabel,
|
||||||
|
generation: generation,
|
||||||
|
fetchOutcome: fetchOutcome,
|
||||||
|
onAuthRejected: invalidateCachedToken,
|
||||||
|
);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
|
if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) {
|
||||||
|
await markUsed?.call();
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final fetched = await _classifyServerFetch(fetchOutcome);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
|
|
||||||
|
switch (fetched.status) {
|
||||||
|
case _ServerFetchStatus.success:
|
||||||
|
final servers = fetched.servers;
|
||||||
|
appLogger.i(
|
||||||
|
'ActiveProfileBinder: using ${usingCachedToken ? "cached" : "fresh"} token for '
|
||||||
|
'$profileLabel (${servers.length} servers)',
|
||||||
|
);
|
||||||
|
unawaited(_persistRefreshedServers(account, servers));
|
||||||
|
final result = await _connectFromServers(account, token, servers, profileLabel);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
|
await markUsed?.call();
|
||||||
|
return result;
|
||||||
|
case _ServerFetchStatus.empty:
|
||||||
|
if (usingCachedToken) {
|
||||||
|
appLogger.w(
|
||||||
|
'ActiveProfileBinder: cached token returned 0 servers for $profileLabel — wiping and re-minting',
|
||||||
|
);
|
||||||
|
await invalidateCachedToken();
|
||||||
|
userToken = null;
|
||||||
|
usingCachedToken = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final result = await _connectFromServers(account, token, const <PlexServer>[], profileLabel);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
|
await markUsed?.call();
|
||||||
|
return result;
|
||||||
|
case _ServerFetchStatus.authRejected:
|
||||||
|
if (usingCachedToken) {
|
||||||
|
final error = fetched.error as MediaServerHttpException;
|
||||||
|
appLogger.w(
|
||||||
|
'ActiveProfileBinder: cached token rejected (${error.statusCode}) for $profileLabel — re-minting',
|
||||||
|
);
|
||||||
|
await invalidateCachedToken();
|
||||||
|
userToken = null;
|
||||||
|
usingCachedToken = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appLogger.w(
|
||||||
|
'ActiveProfileBinder: freshly minted token rejected for $profileLabel',
|
||||||
|
error: fetched.error,
|
||||||
|
stackTrace: fetched.stackTrace,
|
||||||
|
);
|
||||||
|
serverManager.markPlexConnectionAuthError(account);
|
||||||
|
return _ProfileBindResult.visible(account.servers.map((server) => server.clientIdentifier).toSet());
|
||||||
|
case _ServerFetchStatus.transientFailure:
|
||||||
|
appLogger.w(
|
||||||
|
'ActiveProfileBinder: resource refresh failed for $profileLabel; using cached metadata',
|
||||||
|
error: fetched.error,
|
||||||
|
stackTrace: fetched.stackTrace,
|
||||||
|
);
|
||||||
|
// The optimistic pass already probed these endpoints.
|
||||||
|
if (optimistic != null) return optimistic;
|
||||||
|
final result = await _connectFromCachedServers(
|
||||||
|
account,
|
||||||
|
token,
|
||||||
|
profileLabel,
|
||||||
|
error: fetched.error,
|
||||||
|
stackTrace: fetched.stackTrace,
|
||||||
|
);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
||||||
|
if (result.visibleServerIds.isNotEmpty) await markUsed?.call();
|
||||||
|
return result;
|
||||||
|
case _ServerFetchStatus.cancelled:
|
||||||
|
appLogger.d('ActiveProfileBinder: resource refresh cancelled for $profileLabel');
|
||||||
|
return const _ProfileBindResult.empty();
|
||||||
|
case _ServerFetchStatus.failure:
|
||||||
|
appLogger.w(
|
||||||
|
'ActiveProfileBinder: resource refresh failed for $profileLabel',
|
||||||
|
error: fetched.error,
|
||||||
|
stackTrace: fetched.stackTrace,
|
||||||
|
);
|
||||||
|
return const _ProfileBindResult.empty();
|
||||||
}
|
}
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st);
|
|
||||||
return const _ProfileBindResult.empty();
|
|
||||||
}
|
}
|
||||||
if (servers.isNotEmpty) {
|
|
||||||
unawaited(_persistRefreshedServers(account, servers));
|
|
||||||
}
|
|
||||||
return _connectFromServers(account, userToken, servers, profileLabel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_ProfileBindResult> _connectFromCachedServers(
|
Future<_ProfileBindResult> _connectFromCachedServers(
|
||||||
@@ -792,15 +804,25 @@ class ActiveProfileBinder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rethrow a settled fetch with its original error/stack so existing
|
Future<_ClassifiedFetch> _classifyServerFetch(Future<_FetchOutcome> outcome) async {
|
||||||
/// `on MediaServerHttpException` handlers keep working unchanged.
|
|
||||||
Future<List<PlexServer>> _unwrapServerFetch(Future<_FetchOutcome> outcome) async {
|
|
||||||
final settled = await outcome;
|
final settled = await outcome;
|
||||||
final error = settled.error;
|
final servers = settled.servers;
|
||||||
if (error != null) {
|
if (servers != null) {
|
||||||
Error.throwWithStackTrace(error, settled.stackTrace ?? StackTrace.current);
|
return (
|
||||||
|
status: servers.isEmpty ? _ServerFetchStatus.empty : _ServerFetchStatus.success,
|
||||||
|
servers: servers,
|
||||||
|
error: null,
|
||||||
|
stackTrace: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return settled.servers!;
|
final error = settled.error!;
|
||||||
|
final status = switch (error) {
|
||||||
|
MediaServerHttpException(isCancellation: true) => _ServerFetchStatus.cancelled,
|
||||||
|
MediaServerHttpException(statusCode: 401 || 403) => _ServerFetchStatus.authRejected,
|
||||||
|
MediaServerHttpException(isTransient: true) => _ServerFetchStatus.transientFailure,
|
||||||
|
_ => _ServerFetchStatus.failure,
|
||||||
|
};
|
||||||
|
return (status: status, servers: const <PlexServer>[], error: error, stackTrace: settled.stackTrace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a freshly fetched resource list onto the stored account row so
|
/// Persist a freshly fetched resource list onto the stored account row so
|
||||||
@@ -834,6 +856,7 @@ class ActiveProfileBinder {
|
|||||||
required String userToken,
|
required String userToken,
|
||||||
required String profileId,
|
required String profileId,
|
||||||
required String profileLabel,
|
required String profileLabel,
|
||||||
|
required int generation,
|
||||||
required Future<_FetchOutcome> fetchOutcome,
|
required Future<_FetchOutcome> fetchOutcome,
|
||||||
required Future<void> Function() onAuthRejected,
|
required Future<void> Function() onAuthRejected,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -850,6 +873,7 @@ class ActiveProfileBinder {
|
|||||||
account: account,
|
account: account,
|
||||||
profileId: profileId,
|
profileId: profileId,
|
||||||
profileLabel: profileLabel,
|
profileLabel: profileLabel,
|
||||||
|
generation: generation,
|
||||||
onAuthRejected: onAuthRejected,
|
onAuthRejected: onAuthRejected,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
@@ -868,6 +892,7 @@ class ActiveProfileBinder {
|
|||||||
required PlexAccountConnection account,
|
required PlexAccountConnection account,
|
||||||
required String profileId,
|
required String profileId,
|
||||||
required String profileLabel,
|
required String profileLabel,
|
||||||
|
required int generation,
|
||||||
required Future<void> Function() onAuthRejected,
|
required Future<void> Function() onAuthRejected,
|
||||||
}) {
|
}) {
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -884,7 +909,7 @@ class ActiveProfileBinder {
|
|||||||
// Wipe the bad token regardless of the active profile (DB hygiene),
|
// Wipe the bad token regardless of the active profile (DB hygiene),
|
||||||
// but only surface the auth banner while this profile is active.
|
// but only surface the auth banner while this profile is active.
|
||||||
await onAuthRejected();
|
await onAuthRejected();
|
||||||
if (activeProfile.activeId == profileId) {
|
if (_isCurrentBind(profileId, generation)) {
|
||||||
serverManager.markPlexConnectionAuthError(account);
|
serverManager.markPlexConnectionAuthError(account);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -907,7 +932,7 @@ class ActiveProfileBinder {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _persistRefreshedServers(account, fresh);
|
await _persistRefreshedServers(account, fresh);
|
||||||
if (activeProfile.activeId != profileId) return;
|
if (!_isCurrentBind(profileId, generation)) return;
|
||||||
final freshIds = fresh.map((server) => server.clientIdentifier).toSet();
|
final freshIds = fresh.map((server) => server.clientIdentifier).toSet();
|
||||||
final cachedIds = account.servers.map((server) => server.clientIdentifier).toSet();
|
final cachedIds = account.servers.map((server) => server.clientIdentifier).toSet();
|
||||||
if (!setEquals(freshIds, cachedIds)) {
|
if (!setEquals(freshIds, cachedIds)) {
|
||||||
@@ -936,8 +961,15 @@ class ActiveProfileBinder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_ProfileBindResult> _bindJellyfin(JellyfinConnection conn) async {
|
Future<_ProfileBindResult> _bindJellyfin(
|
||||||
|
JellyfinConnection conn, {
|
||||||
|
required String profileId,
|
||||||
|
required int generation,
|
||||||
|
}) async {
|
||||||
final ok = await serverManager.addJellyfinConnection(conn);
|
final ok = await serverManager.addJellyfinConnection(conn);
|
||||||
|
if (!_isCurrentBind(profileId, generation)) {
|
||||||
|
return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId});
|
||||||
|
}
|
||||||
// `addJellyfinConnection` registers the client even when the health probe
|
// `addJellyfinConnection` registers the client even when the health probe
|
||||||
// returns authError. Keep that server in the active profile's visibility
|
// returns authError. Keep that server in the active profile's visibility
|
||||||
// filter so the re-auth banner can surface it instead of hiding it as if
|
// filter so the re-auth banner can surface it instead of hiding it as if
|
||||||
@@ -948,6 +980,10 @@ class ActiveProfileBinder {
|
|||||||
return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId});
|
return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isCurrentBind(String profileId, int generation) {
|
||||||
|
return _bindGeneration == generation && activeProfile.activeId == profileId;
|
||||||
|
}
|
||||||
|
|
||||||
Future<PlexAuthService> _ensureAuth() async {
|
Future<PlexAuthService> _ensureAuth() async {
|
||||||
return _plexAuth ??= await PlexAuthService.create();
|
return _plexAuth ??= await PlexAuthService.create();
|
||||||
}
|
}
|
||||||
@@ -972,6 +1008,7 @@ class ActiveProfileBinder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_bindGeneration++;
|
||||||
if (!_started) return;
|
if (!_started) return;
|
||||||
activeProfile.removeListener(_onActiveProfileChanged);
|
activeProfile.removeListener(_onActiveProfileChanged);
|
||||||
_plexHomePreVerified.clear();
|
_plexHomePreVerified.clear();
|
||||||
|
|||||||
@@ -579,6 +579,175 @@ void main() {
|
|||||||
expect(multiServerProvider.onlineServerIds, ['srv-1']);
|
expect(multiServerProvider.onlineServerIds, ['srv-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('shared Plex server fetch policy', () {
|
||||||
|
Future<({Profile profile, PlexAccountConnection account})> prepareLocalPlex({
|
||||||
|
required http.Client httpClient,
|
||||||
|
required String? cachedToken,
|
||||||
|
List<PlexServer> cachedServers = const [],
|
||||||
|
MultiServerManager? testManager,
|
||||||
|
}) async {
|
||||||
|
binder.dispose();
|
||||||
|
multiServerProvider.dispose();
|
||||||
|
|
||||||
|
manager = testManager ?? _CapturingMultiServerManager();
|
||||||
|
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||||
|
binder = ActiveProfileBinder(
|
||||||
|
activeProfile: activeProfile,
|
||||||
|
connections: connections,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
serverManager: manager,
|
||||||
|
multiServerProvider: multiServerProvider,
|
||||||
|
pinPrompt: (_, {String? errorMessage}) async => null,
|
||||||
|
shouldDeferInitialBind: (_) async => false,
|
||||||
|
plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final account = PlexAccountConnection(
|
||||||
|
id: 'plex.policy',
|
||||||
|
accountToken: 'account-token',
|
||||||
|
clientIdentifier: 'client-id',
|
||||||
|
accountLabel: 'Owner',
|
||||||
|
servers: cachedServers,
|
||||||
|
createdAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
await connections.upsert(account);
|
||||||
|
final profile = await createActiveLocalProfile('local-policy');
|
||||||
|
await profileConnections.upsert(
|
||||||
|
ProfileConnection(
|
||||||
|
profileId: profile.id,
|
||||||
|
connectionId: account.id,
|
||||||
|
userToken: cachedToken,
|
||||||
|
userIdentifier: 'home-user-uuid',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return (profile: profile, account: account);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cached token success connects the fetched servers without minting', () async {
|
||||||
|
var switchCalls = 0;
|
||||||
|
final capturing = _CapturingMultiServerManager();
|
||||||
|
final prepared = await prepareLocalPlex(
|
||||||
|
cachedToken: 'cached-user-token',
|
||||||
|
testManager: capturing,
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
|
||||||
|
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await binder.rebindActive();
|
||||||
|
|
||||||
|
expect(switchCalls, 0);
|
||||||
|
expect(capturing.refreshCalls, 1);
|
||||||
|
expect(capturing.lastConnection?.servers.single.accessToken, 'server-token');
|
||||||
|
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
|
||||||
|
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing token mints once before fetching and persists the fresh token', () async {
|
||||||
|
var switchCalls = 0;
|
||||||
|
var resourceCalls = 0;
|
||||||
|
final prepared = await prepareLocalPlex(
|
||||||
|
cachedToken: null,
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) {
|
||||||
|
switchCalls++;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'authToken': 'fresh-user-token'}),
|
||||||
|
201,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resourceCalls++;
|
||||||
|
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await binder.rebindActive();
|
||||||
|
|
||||||
|
expect(switchCalls, 1);
|
||||||
|
expect(resourceCalls, 1);
|
||||||
|
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'fresh-user-token');
|
||||||
|
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cached auth rejection invalidates and remints, while a fatal error does not', () async {
|
||||||
|
var resourceCalls = 0;
|
||||||
|
var switchCalls = 0;
|
||||||
|
final prepared = await prepareLocalPlex(
|
||||||
|
cachedToken: 'rejected-token',
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) {
|
||||||
|
switchCalls++;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'authToken': 'replacement-token'}),
|
||||||
|
201,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resourceCalls++;
|
||||||
|
if (resourceCalls == 1) {
|
||||||
|
return http.Response('{}', 401, headers: {'content-type': 'application/json'});
|
||||||
|
}
|
||||||
|
return http.Response('{}', 500, headers: {'content-type': 'application/json'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await binder.rebindActive();
|
||||||
|
|
||||||
|
expect(resourceCalls, 2);
|
||||||
|
expect(switchCalls, 1);
|
||||||
|
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'replacement-token');
|
||||||
|
expect(activeProfile.lastBindingSucceeded, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelled fetch does not invalidate or remint the cached token', () async {
|
||||||
|
var switchCalls = 0;
|
||||||
|
final prepared = await prepareLocalPlex(
|
||||||
|
cachedToken: 'cached-user-token',
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
|
||||||
|
throw http.RequestAbortedException(request.url);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await binder.rebindActive();
|
||||||
|
|
||||||
|
expect(switchCalls, 0);
|
||||||
|
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
|
||||||
|
expect(activeProfile.lastBindingSucceeded, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile switch cancels stale zero-server handling before invalidation or remint', () async {
|
||||||
|
final requestStarted = Completer<void>();
|
||||||
|
final releaseRequest = Completer<void>();
|
||||||
|
var switchCalls = 0;
|
||||||
|
final prepared = await prepareLocalPlex(
|
||||||
|
cachedToken: 'cached-user-token',
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
|
||||||
|
if (!requestStarted.isCompleted) requestStarted.complete();
|
||||||
|
await releaseRequest.future;
|
||||||
|
return http.Response('[]', 200, headers: {'content-type': 'application/json'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final nextProfile = Profile.local(id: 'local-next', displayName: 'Next', createdAt: DateTime(2026, 1, 2));
|
||||||
|
await profiles.upsert(nextProfile);
|
||||||
|
await pumpUntil(() async => activeProfile.profiles.any((profile) => profile.id == nextProfile.id));
|
||||||
|
|
||||||
|
binder.start();
|
||||||
|
await requestStarted.future;
|
||||||
|
await activeProfile.activate(nextProfile);
|
||||||
|
releaseRequest.complete();
|
||||||
|
await activeProfile.awaitBindingSettle();
|
||||||
|
|
||||||
|
expect(switchCalls, 0);
|
||||||
|
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
|
||||||
|
expect(binder.debugLastBoundProfileId, nextProfile.id);
|
||||||
|
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('rebind cycle semantics', () {
|
group('rebind cycle semantics', () {
|
||||||
test('queued same-id rebind settles once, after the last pass', () async {
|
test('queued same-id rebind settles once, after the last pass', () async {
|
||||||
binder.dispose();
|
binder.dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user