mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-05 15:41:23 +03:00
* 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>
265 lines
8.4 KiB
Dart
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();
|
|
}
|
|
}
|