Files
rustdesk/flutter/lib/models/user_model.dart
RustDesk ffe20bb297 Login options error feedback (#15727)
* fix(flutter): show error and retry when fetching login options fails

The third-party login section of the login dialog was silently hidden
whenever /api/login-options could not be fetched (e.g. TLS handshake
aborted by a router/ISP scam filter, discussion #15700), leaving users
staring at a dialog with no feedback. The pure-Dart HTTP path also had
no timeout, so a black-holed connection could hang indefinitely.

- let transport errors propagate from queryOidcLoginOptions instead of
  swallowing them; a non-JSON response still means "no third-party
  login" so self-hosted servers without this API keep the old behavior
- show network_error_tip, a Retry button, and the underlying error in
  the login dialog so users and supporters can see what failed
- bound the Dart HTTP branch with a 15s timeout; the Rust branch keeps
  its own bounded per-attempt timeouts and is awaited to completion so
  a retry never races the URL-keyed ASYNC_HTTP_STATUS entry of an
  abandoned in-flight request

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flutter): surface currentUser refresh failures that were only logged

Non-transport failures of the token auto-login (/api/currentUser) -- a
bad HTTP status, a filter's HTML block page, or an error field in the
body -- were only debugPrinted, so the address book / group tabs showed
nothing and offered no retry. Reuse the existing networkError channel
so netWorkErrorWidget shows the error with its Retry button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flutter): keep retry row visible with progress while refetching login options

Review follow-ups: clicking Retry used to clear the error and hide the
row with no pending feedback, which could read as a dead click while
the Rust fallback chain runs; keep the row, disable the button, and
show the usual LinearProgressIndicator instead. Also raise the Dart
HTTP branch timeout to 30s so large web address book pulls on slow
links do not newly time out; it still bounds the previously unbounded
hang and stays above the Rust side's 12s per-attempt timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update webpki-roots to latest Mozilla root store

0.26.9 -> 0.26.11 (now a forwarding shim over 1.x, used by tungstenite)
1.0.4 -> 1.0.9 (used by reqwest / hyper-rustls / hbb_common)

The 0.26.9 line carried its own root snapshot frozen in early 2025, so
the websocket TLS path was building against a stale bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: weekly workflow to PR webpki-roots root store updates

webpki-roots is a transitive dependency, so dependabot's cargo version
updates would not cover it. A scheduled job runs cargo update for every
webpki-roots instance in each lockfile and opens a PR when the pinned
Mozilla root snapshot is behind, keeping root store changes reviewable
instead of baking them silently into release builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flutter): hide network tip for server-reported currentUser errors

Review follow-up: when /api/currentUser fails with an error the server
itself reported (an error field in a JSON body, or an unexpected
schema), "Please check your network connection" was misleading. Track
whether the surfaced error came from a server response and skip the
network tip for those; FormatException (a non-JSON body such as a
filter's block page) keeps it, since that still indicates a network or
middlebox problem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flutter): close timed-out HTTP clients

* fix(flutter): flag server-reported errors at the throw site

Review follow-up (CodeRabbit). Classifying by `e is! FormatException`
mislabeled ambiguous failures: a middlebox block page returning 200
with valid-but-wrong-shape JSON throws a TypeError from fromJson and
was shown without the check-your-network tip, though it is a network
artifact. Set networkErrorFromServer only at the one site that is
certainly server-reported (an error field in the body); every other
failure keeps the network tip plus the raw error text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: serialize webpki-roots update runs, null-delimit lockfile paths

Review follow-up (CodeRabbit). A manual dispatch overlapping the weekly
cron could have an older run force-push over the newer branch state;
queue runs via a concurrency group without cancel-in-progress. Also
iterate lockfiles with git ls-files -z so a path with spaces cannot be
word-split, and keep the loop failing the step on any cargo error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flutter): improve login retry feedback

Use the theme primary color for the Retry button and hide stale
error messages while a retry is in progress.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(flutter): surface login option response errors

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-08-04 10:19:53 +08:00

265 lines
8.4 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:bot_toast/bot_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/models/ab_model.dart';
import 'package:get/get.dart';
import '../common.dart';
import '../utils/http_service.dart' as http;
import 'model.dart';
import 'platform_model.dart';
bool refreshingUser = false;
class UserModel {
final RxString userName = ''.obs;
final RxString displayName = ''.obs;
final RxString avatar = ''.obs;
final RxBool isAdmin = false.obs;
final RxString networkError = ''.obs;
// True when networkError carries a server-reported error rather than a
// connectivity failure; netWorkErrorWidget hides the network tip then.
final RxBool networkErrorFromServer = false.obs;
bool get isLogin => userName.isNotEmpty;
String get displayNameOrUserName =>
displayName.value.trim().isEmpty ? userName.value : displayName.value;
String get accountLabelWithHandle {
final username = userName.value.trim();
if (username.isEmpty) {
return '';
}
final preferred = displayName.value.trim();
if (preferred.isEmpty || preferred == username) {
return username;
}
return '$preferred (@$username)';
}
WeakReference<FFI> parent;
UserModel(this.parent) {
userName.listen((p0) {
// When user name becomes empty, show login button
// When user name becomes non-empty:
// For _updateLocalUserInfo, network error will be set later
// For login success, should clear network error
networkError.value = '';
});
}
void refreshCurrentUser() async {
if (bind.isDisableAccount()) return;
networkError.value = '';
networkErrorFromServer.value = false;
final token = bind.mainGetLocalOption(key: 'access_token');
if (token == '') {
await updateOtherModels();
return;
}
_updateLocalUserInfo();
final url = await bind.mainGetApiServer();
final body = {
'id': await bind.mainGetMyId(),
'uuid': await bind.mainGetUuid()
};
if (refreshingUser) return;
try {
refreshingUser = true;
final http.Response response;
try {
response = await http.post(Uri.parse('$url/api/currentUser'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
body: json.encode(body));
} catch (e) {
networkError.value = e.toString();
rethrow;
}
refreshingUser = false;
final status = response.statusCode;
if (status == 401 || status == 400) {
reset(resetOther: status == 401);
return;
}
final data = json.decode(decode_http_response(response));
final error = data['error'];
if (error != null) {
// The only failure known to come from the server itself, so the
// check-your-network tip does not apply. Flag before the message is
// set in the catch below so rebuilds read a consistent pair.
networkErrorFromServer.value = true;
throw error;
}
final user = UserPayload.fromJson(data);
_parseAndUpdateUser(user);
} catch (e) {
debugPrint('Failed to refreshCurrentUser: $e');
// Surface failures in the address book / group tabs, which offer a
// retry. Anything not flagged above -- transport errors, non-JSON or
// unexpected-schema bodies (e.g. a filter's block page) -- keeps the
// check-your-network tip.
if (networkError.value.isEmpty) {
networkError.value = e.toString();
}
} finally {
refreshingUser = false;
await updateOtherModels();
}
}
static Map<String, dynamic>? getLocalUserInfo() {
final userInfo = bind.mainGetLocalOption(key: 'user_info');
if (userInfo == '') {
return null;
}
try {
return json.decode(userInfo);
} catch (e) {
debugPrint('Failed to get local user info "$userInfo": $e');
}
return null;
}
_updateLocalUserInfo() {
final userInfo = getLocalUserInfo();
if (userInfo != null) {
userName.value = (userInfo['name'] ?? '').toString();
displayName.value = (userInfo['display_name'] ?? '').toString();
avatar.value = (userInfo['avatar'] ?? '').toString();
}
}
Future<void> reset({bool resetOther = false}) async {
await bind.mainSetLocalOption(key: 'access_token', value: '');
await bind.mainSetLocalOption(key: 'user_info', value: '');
if (resetOther) {
await gFFI.abModel.reset();
await gFFI.groupModel.reset();
}
userName.value = '';
displayName.value = '';
avatar.value = '';
}
_parseAndUpdateUser(UserPayload user) {
userName.value = user.name;
displayName.value = user.displayName;
avatar.value = user.avatar;
isAdmin.value = user.isAdmin;
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
if (isWeb) {
// ugly here, tmp solution
bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? '');
}
}
// update ab and group status
static Future<void> updateOtherModels() async {
await Future.wait([
gFFI.abModel.pullAb(force: ForcePullAb.listAndCurrent, quiet: false),
gFFI.groupModel.pull()
]);
}
Future<void> logOut({String? apiServer}) async {
final tag = gFFI.dialogManager.showLoading(translate('Waiting'));
try {
final url = apiServer ?? await bind.mainGetApiServer();
final authHeaders = getHttpHeaders();
authHeaders['Content-Type'] = "application/json";
await http
.post(Uri.parse('$url/api/logout'),
body: jsonEncode({
'id': await bind.mainGetMyId(),
'uuid': await bind.mainGetUuid(),
}),
headers: authHeaders)
.timeout(Duration(seconds: 2));
} catch (e) {
debugPrint("request /api/logout failed: err=$e");
} finally {
await reset(resetOther: true);
gFFI.dialogManager.dismissByTag(tag);
}
}
/// throw [RequestException]
Future<LoginResponse> login(LoginRequest loginRequest) async {
final url = await bind.mainGetApiServer();
final resp = await http.post(Uri.parse('$url/api/login'),
body: jsonEncode(loginRequest.toJson()));
final Map<String, dynamic> body;
try {
body = jsonDecode(decode_http_response(resp));
} catch (e) {
debugPrint("login: jsonDecode resp body failed: ${e.toString()}");
if (resp.statusCode != 200) {
BotToast.showText(
contentColor: Colors.red, text: 'HTTP ${resp.statusCode}');
}
rethrow;
}
if (resp.statusCode != 200) {
throw RequestException(resp.statusCode, body['error'] ?? '');
}
if (body['error'] != null) {
throw RequestException(0, body['error']);
}
return getLoginResponseFromAuthBody(body);
}
LoginResponse getLoginResponseFromAuthBody(Map<String, dynamic> body) {
final LoginResponse loginResponse;
try {
loginResponse = LoginResponse.fromJson(body);
} catch (e) {
debugPrint("login: jsonDecode LoginResponse failed: ${e.toString()}");
rethrow;
}
final isLogInDone = loginResponse.type == HttpType.kAuthResTypeToken &&
loginResponse.access_token != null;
if (isLogInDone && loginResponse.user != null) {
_parseAndUpdateUser(loginResponse.user!);
}
return loginResponse;
}
/// Throws on network failure so callers can surface the error and offer a
/// retry; returns an empty list when the server has no third-party login.
static Future<List<dynamic>> queryOidcLoginOptions() async {
final url = await bind.mainGetApiServer();
if (url.trim().isEmpty) return [];
final resp = await http.get(Uri.parse('$url/api/login-options'));
const successStatusCodeStart = 200;
const successStatusCodeEnd = 300;
if (resp.statusCode < successStatusCodeStart ||
resp.statusCode >= successStatusCodeEnd) {
throw RequestException(
resp.statusCode, resp.reasonPhrase ?? 'Request failed');
}
final List<String> ops = [];
for (final item in jsonDecode(resp.body)) {
ops.add(item as String);
}
for (final item in ops) {
if (item.startsWith('common-oidc/')) {
return jsonDecode(item.substring('common-oidc/'.length));
}
}
return ops
.where((item) => item.startsWith('oidc/'))
.map((item) => {'name': item.substring('oidc/'.length)})
.toList();
}
}