diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index ab1b0b3c5..af87f980f 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -4118,3 +4118,43 @@ String mouseButtonsToPeer(int buttons) { return ''; } } + +/// Build an avatar widget from an avatar URL or data URI string. +/// Returns [fallback] if avatar is empty or cannot be decoded. +/// [borderRadius] defaults to [size]/2 (circle). +Widget? buildAvatarWidget({ + required String avatar, + required double size, + double? borderRadius, + Widget? fallback, +}) { + final trimmed = avatar.trim(); + if (trimmed.isEmpty) return fallback; + + ImageProvider? imageProvider; + if (trimmed.startsWith('data:image/')) { + final comma = trimmed.indexOf(','); + if (comma > 0) { + try { + imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1))); + } catch (_) {} + } + } else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + imageProvider = NetworkImage(trimmed); + } + + if (imageProvider == null) return fallback; + + final radius = borderRadius ?? size / 2; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image( + image: imageProvider, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + fallback ?? SizedBox.shrink(), + ), + ); +} diff --git a/flutter/lib/common/hbbs/hbbs.dart b/flutter/lib/common/hbbs/hbbs.dart index f3b210184..0c729e4df 100644 --- a/flutter/lib/common/hbbs/hbbs.dart +++ b/flutter/lib/common/hbbs/hbbs.dart @@ -26,6 +26,7 @@ enum UserStatus { kDisabled, kNormal, kUnverified } class UserPayload { String name = ''; String displayName = ''; + String avatar = ''; String email = ''; String note = ''; String? verifier; @@ -35,6 +36,7 @@ class UserPayload { UserPayload.fromJson(Map json) : name = json['name'] ?? '', displayName = json['display_name'] ?? '', + avatar = json['avatar'] ?? '', email = json['email'] ?? '', note = json['note'] ?? '', verifier = json['verifier'], @@ -49,6 +51,7 @@ class UserPayload { final Map map = { 'name': name, 'display_name': displayName, + 'avatar': avatar, 'status': status == UserStatus.kDisabled ? 0 : status == UserStatus.kUnverified diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d8239adea..bde40cf19 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -2026,28 +2026,65 @@ class _AccountState extends State<_Account> { } Widget useInfo() { - text(String key, String value) { - return Align( - alignment: Alignment.centerLeft, - child: SelectionArea(child: Text('${translate(key)}: $value')) - .marginSymmetric(vertical: 4), - ); - } - return Obx(() => Offstage( offstage: gFFI.userModel.userName.value.isEmpty, - child: Column( - children: [ - if (gFFI.userModel.displayName.value.trim().isNotEmpty && - gFFI.userModel.displayName.value.trim() != - gFFI.userModel.userName.value.trim()) - text('Display Name', gFFI.userModel.displayName.value.trim()), - text('Username', gFFI.userModel.userName.value), - // text('Group', gFFI.groupModel.groupName.value), - ], + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Builder(builder: (context) { + final avatarWidget = _buildUserAvatar(); + return Row( + children: [ + if (avatarWidget != null) avatarWidget, + if (avatarWidget != null) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + gFFI.userModel.displayNameOrUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectionArea( + child: Text( + '@${gFFI.userModel.userName.value}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: + Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + ], + ), + ), + ], + ); + }), ), )).marginOnly(left: 18, top: 16); } + + Widget? _buildUserAvatar() { + // Resolve relative avatar path at display time + final avatar = + bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 44, + ); + } } class _Checkbox extends StatefulWidget { diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 4ee29756f..ea37c95e4 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -462,23 +462,7 @@ class _CmHeaderState extends State<_CmHeader> child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 70, - height: 70, - alignment: Alignment.center, - decoration: BoxDecoration( - color: str2color(client.name), - borderRadius: BorderRadius.circular(15.0), - ), - child: Text( - client.name[0], - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 55, - ), - ), - ).marginOnly(right: 10.0), + _buildClientAvatar().marginOnly(right: 10.0), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -582,6 +566,35 @@ class _CmHeaderState extends State<_CmHeader> @override bool get wantKeepAlive => true; + + Widget _buildClientAvatar() { + return buildAvatarWidget( + avatar: client.avatar, + size: 70, + borderRadius: 15, + fallback: _buildInitialAvatar(), + )!; + } + + Widget _buildInitialAvatar() { + return Container( + width: 70, + height: 70, + alignment: Alignment.center, + decoration: BoxDecoration( + color: str2color(client.name), + borderRadius: BorderRadius.circular(15.0), + ), + child: Text( + client.name.isNotEmpty ? client.name[0] : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 55, + ), + ), + ); + } } class _PrivilegeBoard extends StatefulWidget { diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index d2a6ed8a8..d0a7b573e 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -841,13 +841,7 @@ class ClientInfo extends StatelessWidget { flex: -1, child: Padding( padding: const EdgeInsets.only(right: 12), - child: CircleAvatar( - backgroundColor: str2color( - client.name, - Theme.of(context).brightness == Brightness.light - ? 255 - : 150), - child: Text(client.name[0])))), + child: _buildAvatar(context))), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -860,6 +854,20 @@ class ClientInfo extends StatelessWidget { ), ])); } + + Widget _buildAvatar(BuildContext context) { + final fallback = CircleAvatar( + backgroundColor: str2color( + client.name, + Theme.of(context).brightness == Brightness.light ? 255 : 150), + child: Text(client.name.isNotEmpty ? client.name[0] : '?'), + ); + return buildAvatarWidget( + avatar: client.avatar, + size: 40, + fallback: fallback, + )!; + } } void androidChannelInit() { diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index afd3422d7..e047344ae 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -689,7 +689,15 @@ class _SettingsState extends State with WidgetsBindingObserver { title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty ? translate('Login') : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')), - leading: Icon(Icons.person), + leading: Obx(() { + final avatar = bind.mainResolveAvatarUrl( + avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 40, + ) ?? + Icon(Icons.person); + }), onPressed: (context) { if (gFFI.userModel.userName.value.isEmpty) { loginDialog(); diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 8ead158ac..5892ed0fe 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -820,6 +820,7 @@ class Client { bool isTerminal = false; String portForward = ""; String name = ""; + String avatar = ""; String peerId = ""; // peer user's id,show at app bool keyboard = false; bool clipboard = false; @@ -847,6 +848,7 @@ class Client { isTerminal = json['is_terminal'] ?? false; portForward = json['port_forward']; name = json['name']; + avatar = json['avatar'] ?? ''; peerId = json['peer_id']; keyboard = json['keyboard']; clipboard = json['clipboard']; @@ -870,6 +872,7 @@ class Client { data['is_terminal'] = isTerminal; data['port_forward'] = portForward; data['name'] = name; + data['avatar'] = avatar; data['peer_id'] = peerId; data['keyboard'] = keyboard; data['clipboard'] = clipboard; diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index c850c4cf6..cecb58eaa 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -17,6 +17,7 @@ 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; bool get isLogin => userName.isNotEmpty; @@ -33,6 +34,7 @@ class UserModel { } return '$preferred (@$username)'; } + WeakReference parent; UserModel(this.parent) { @@ -114,6 +116,7 @@ class UserModel { if (userInfo != null) { userName.value = (userInfo['name'] ?? '').toString(); displayName.value = (userInfo['display_name'] ?? '').toString(); + avatar.value = (userInfo['avatar'] ?? '').toString(); } } @@ -126,11 +129,13 @@ class UserModel { } 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) { diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 4a4e89233..66191d004 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -2034,5 +2034,9 @@ class RustdeskImpl { return false; } + String mainResolveAvatarUrl({required String avatar, dynamic hint}) { + return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar; + } + void dispose() {} } diff --git a/src/client.rs b/src/client.rs index cb4ed3a24..8ea70898f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,7 +33,7 @@ use crate::{ create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, kcp_stream::KcpStream, secure_tcp, - ui_interface::{get_builtin_option, use_texture_render}, + ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render}, ui_session_interface::{InvokeUiSession, Session}, }; #[cfg(feature = "unix-file-copy-paste")] @@ -2625,6 +2625,20 @@ impl LoginConfigHandler { } else { (my_id, self.id.clone()) }; + let mut avatar = get_builtin_option(keys::OPTION_AVATAR); + if avatar.is_empty() { + avatar = serde_json::from_str::(&LocalConfig::get_option( + "user_info", + )) + .ok() + .and_then(|x| { + x.get("avatar") + .and_then(|x| x.as_str()) + .map(|x| x.trim().to_owned()) + }) + .unwrap_or_default(); + } + avatar = resolve_avatar_url(avatar); let mut display_name = get_builtin_option(keys::OPTION_DISPLAY_NAME); if display_name.is_empty() { display_name = @@ -2684,6 +2698,7 @@ impl LoginConfigHandler { }) .into(), hwid, + avatar, ..Default::default() }; match self.conn_type { diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index ed13a7624..551ad799f 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1101,6 +1101,10 @@ pub fn main_get_api_server() -> String { get_api_server() } +pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn { + SyncReturn(resolve_avatar_url(avatar)) +} + pub fn main_http_request(url: String, method: String, body: Option, header: String) { http_request(url, method, body, header) } diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 6644aee28..8e6141200 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -17,6 +17,7 @@ lazy_static::lazy_static! { const QUERY_INTERVAL_SECS: f32 = 1.0; const QUERY_TIMEOUT_SECS: u64 = 60 * 3; + const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth"; const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth"; const LOGIN_ACCOUNT_AUTH: &str = "Login account auth"; @@ -82,6 +83,8 @@ pub struct UserPayload { #[serde(default)] pub display_name: Option, #[serde(default)] + pub avatar: Option, + #[serde(default)] pub email: Option, #[serde(default)] pub note: Option, @@ -273,6 +276,7 @@ impl OidcSession { serde_json::json!({ "name": auth_body.user.name, "display_name": auth_body.user.display_name, + "avatar": auth_body.user.avatar, "status": auth_body.user.status }) .to_string(), diff --git a/src/ipc.rs b/src/ipc.rs index a5d27ba8a..891ec81dd 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -226,6 +226,7 @@ pub enum Data { is_terminal: bool, peer_id: String, name: String, + avatar: String, authorized: bool, port_forward: String, keyboard: bool, @@ -1583,6 +1584,6 @@ mod test { #[test] fn verify_ffi_enum_data_size() { println!("{}", std::mem::size_of::()); - assert!(std::mem::size_of::() <= 96); + assert!(std::mem::size_of::() <= 120); } } diff --git a/src/server/connection.rs b/src/server/connection.rs index 033aac0ce..1ffb1a25e 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1877,6 +1877,7 @@ impl Connection { port_forward: self.port_forward_address.clone(), peer_id, name, + avatar: self.lr.avatar.clone(), authorized, keyboard: self.keyboard, clipboard: self.clipboard, diff --git a/src/ui/cm.css b/src/ui/cm.css index baa774309..ba6de887b 100644 --- a/src/ui/cm.css +++ b/src/ui/cm.css @@ -57,6 +57,11 @@ div.icon { font-weight: bold; } +img.icon { + size: 96px; + border-radius: 8px; +} + div.id { @ELLIPSIS; color: color(green-blue); diff --git a/src/ui/cm.rs b/src/ui/cm.rs index 92cd2e2f2..15b7b9435 100644 --- a/src/ui/cm.rs +++ b/src/ui/cm.rs @@ -28,6 +28,7 @@ impl InvokeUiCM for SciterHandler { client.port_forward.clone(), client.peer_id.clone(), client.name.clone(), + client.avatar.clone(), client.authorized, client.keyboard, client.clipboard, diff --git a/src/ui/cm.tis b/src/ui/cm.tis index 0b0165b73..a06fb9ff8 100644 --- a/src/ui/cm.tis +++ b/src/ui/cm.tis @@ -42,9 +42,11 @@ class Body: Reactor.Component return
+ {c.avatar ? + :
{c.name[0].toUpperCase()} -
+
}
{c.name}
({c.peer_id})
@@ -366,7 +368,7 @@ function bring_to_top(idx=-1) { } } -handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) { +handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) { stdout.println("new connection #" + id + ": " + peer_id); var conn; connections.map(function(c) { @@ -385,6 +387,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin conn = { id: id, is_file_transfer: is_file_transfer, is_view_camera: is_view_camera, is_terminal: is_terminal, peer_id: peer_id, port_forward: port_forward, + avatar: avatar, name: name, authorized: authorized, time: new Date(), now: new Date(), keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0, audio: audio, file: file, restart: restart, recording: recording, diff --git a/src/ui/index.tis b/src/ui/index.tis index edd69312e..5853fe3e2 100644 --- a/src/ui/index.tis +++ b/src/ui/index.tis @@ -1451,6 +1451,9 @@ function set_local_user_info(user) { if (user.display_name) { user_info.display_name = user.display_name; } + if (user.avatar) { + user_info.avatar = user.avatar; + } if (user.status) { user_info.status = user.status; } diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 4e688429f..75e724007 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -134,6 +134,7 @@ pub struct Client { pub is_terminal: bool, pub port_forward: String, pub name: String, + pub avatar: String, pub peer_id: String, pub keyboard: bool, pub clipboard: bool, @@ -220,6 +221,7 @@ impl ConnectionManager { port_forward: String, peer_id: String, name: String, + avatar: String, authorized: bool, keyboard: bool, clipboard: bool, @@ -240,6 +242,7 @@ impl ConnectionManager { is_terminal, port_forward, name: name.clone(), + avatar, peer_id: peer_id.clone(), keyboard, clipboard, @@ -500,9 +503,9 @@ impl IpcTaskRunner { } Ok(Some(data)) => { match data { - Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => { + Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => { log::debug!("conn_id: {}", id); - self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone()); + self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone()); self.conn_id = id; #[cfg(target_os = "windows")] { @@ -823,6 +826,7 @@ pub async fn start_listen( port_forward, peer_id, name, + avatar, authorized, keyboard, clipboard, @@ -843,6 +847,7 @@ pub async fn start_listen( port_forward, peer_id, name, + avatar, authorized, keyboard, clipboard, diff --git a/src/ui_interface.rs b/src/ui_interface.rs index c5f158c9d..49098f2db 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -245,7 +245,20 @@ pub fn get_builtin_option(key: &str) -> String { #[inline] pub fn set_local_option(key: String, value: String) { - LocalConfig::set_option(key.clone(), value.clone()); + LocalConfig::set_option(key.clone(), value); +} + +/// Resolve relative avatar path (e.g. "/avatar/xxx") to absolute URL +/// by prepending the API server address. +pub fn resolve_avatar_url(avatar: String) -> String { + let avatar = avatar.trim().to_owned(); + if avatar.starts_with('/') { + let api_server = get_api_server(); + if !api_server.is_empty() { + return format!("{}{}", api_server.trim_end_matches('/'), avatar); + } + } + avatar } #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]