Files
rustdesk/flutter/lib/models/user_model.dart
RustDesk 6c3515588f - UI display: display_name first (#14358)
* - UI display: display_name first
  - Fallback: name
  - Technical identity: still name

  ### What changed

  - Added account display helpers and display_name state in user model:
      - flutter/lib/models/user_model.dart:16
  - Account/logout label now uses display_name (@name) when both exist:
      - flutter/lib/mobile/pages/settings_page.dart:689
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2016
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2135
  - Desktop Account info now shows both when applicable:
      - Display Name: ...
      - Username: ...
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2039
  - Previously done group-list behavior remains:
      - group user list displays display_name with name fallback
      - flutter/lib/common/widgets/my_group.dart:187
  - Persistence path for display_name remains enabled (including group cache/submodule field):
      - libs/hbb_common/src/config.rs:2347
  - src/client.rs:2630
  - LoginRequest.my_name now resolves as:
      1. OPTION_DISPLAY_NAME (manual override)
      2. user_info.display_name
      3. user_info.name
      4. OS username fallback

* 1. GUID key (...Uninstall\{GUID}) is MSI-native metadata generated by Windows Installer.
  2. Non-GUID key (...Uninstall\RustDesk) is explicitly written by RustDesk’s MSI compatibility component in res/msi/Package/Components/Regs.wxs:44, populated by preprocess.py --arp from .github/workflows/
     flutter-build.yml:262.

  So they were not using the same EstimatedSize logic:

  - MSI GUID key: MSI-calculated size (KB).
  - RustDesk key: custom script value from res/msi/preprocess.py:339 (previously bytes, now fixed to KB).

  That mismatch is exactly why you saw different sizes.

* improve display name handling

  - Append (@username) when multiple users share the same display name
  - Trim whitespace from display_name before comparison and display
  - Add missing translate() for Logout button on desktop

Signed-off-by: 21pages <sunboeasy@gmail.com>

* group peer filter match both user's display name and user's name

Signed-off-by: 21pages <sunboeasy@gmail.com>

* case-insensitive search in group peer filter

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-02-22 14:59:25 +08:00

242 lines
7.2 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 RxBool isAdmin = false.obs;
final RxString networkError = ''.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 = '';
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) {
throw error;
}
final user = UserPayload.fromJson(data);
_parseAndUpdateUser(user);
} catch (e) {
debugPrint('Failed to refreshCurrentUser: $e');
} 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();
}
}
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 = '';
}
_parseAndUpdateUser(UserPayload user) {
userName.value = user.name;
displayName.value = user.displayName;
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;
}
static Future<List<dynamic>> queryOidcLoginOptions() async {
try {
final url = await bind.mainGetApiServer();
if (url.trim().isEmpty) return [];
final resp = await http.get(Uri.parse('$url/api/login-options'));
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();
} catch (e) {
debugPrint(
"queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}");
return [];
}
}
}