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..36e1f8e9c 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -2026,28 +2026,79 @@ 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() { + final avatar = gFFI.userModel.avatar.value.trim(); + if (avatar.isEmpty) return null; + const radius = 22.0; + if (avatar.startsWith('data:image/')) { + final comma = avatar.indexOf(','); + if (comma > 0) { + try { + return CircleAvatar( + radius: radius, + backgroundImage: MemoryImage(base64Decode(avatar.substring(comma + 1))), + ); + } catch (_) { + return null; + } + } + } else if (avatar.startsWith('http://') || avatar.startsWith('https://')) { + return CircleAvatar( + radius: radius, + backgroundImage: NetworkImage(avatar), + ); + } + return null; + } } class _Checkbox extends StatefulWidget { diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 4ee29756f..57bbdfabf 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -1,6 +1,7 @@ // original cm window in Sciter version. import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -462,23 +463,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 +567,60 @@ class _CmHeaderState extends State<_CmHeader> @override bool get wantKeepAlive => true; + + Widget _buildClientAvatar() { + const borderRadius = BorderRadius.all(Radius.circular(15.0)); + final avatar = client.avatar.trim(); + if (avatar.startsWith('data:image/')) { + final comma = avatar.indexOf(','); + if (comma > 0) { + try { + final bytes = base64Decode(avatar.substring(comma + 1)); + return ClipRRect( + borderRadius: borderRadius, + child: Image.memory( + bytes, + width: 70, + height: 70, + fit: BoxFit.cover, + ), + ); + } catch (_) {} + } + } else if (avatar.startsWith('http://') || avatar.startsWith('https://')) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.network( + avatar, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildInitialAvatar(), + ), + ); + } + return _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[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..feae68a32 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -841,13 +842,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 +855,31 @@ class ClientInfo extends StatelessWidget { ), ])); } + + Widget _buildAvatar(BuildContext context) { + final avatar = client.avatar.trim(); + if (avatar.isNotEmpty) { + if (avatar.startsWith('data:image/')) { + final comma = avatar.indexOf(','); + if (comma > 0) { + try { + return CircleAvatar( + backgroundImage: MemoryImage(base64Decode(avatar.substring(comma + 1))), + ); + } catch (_) {} + } + } else if (avatar.startsWith('http://') || avatar.startsWith('https://')) { + return CircleAvatar(backgroundImage: NetworkImage(avatar)); + } + } + // Show character as before if no avatar + return CircleAvatar( + backgroundColor: str2color( + client.name, + Theme.of(context).brightness == Brightness.light ? 255 : 150), + child: Text(client.name[0]), + ); + } } void androidChannelInit() { 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..3f0e1d463 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; @@ -87,6 +88,7 @@ class UserModel { } final user = UserPayload.fromJson(data); + user.avatar = _resolveAvatar(user.avatar, url); _parseAndUpdateUser(user); } catch (e) { debugPrint('Failed to refreshCurrentUser: $e'); @@ -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,13 +129,16 @@ 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)); + _updateLocalUserInfo(); if (isWeb) { // ugly here, tmp solution bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? ''); diff --git a/src/client.rs b/src/client.rs index cb4ed3a24..cd9715619 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2625,6 +2625,19 @@ 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(); + } let mut display_name = get_builtin_option(keys::OPTION_DISPLAY_NAME); if display_name.is_empty() { display_name = @@ -2684,6 +2697,7 @@ impl LoginConfigHandler { }) .into(), hwid, + avatar, ..Default::default() }; match self.conn_type { diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 6644aee28..1c3d3b994 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -82,6 +82,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 +275,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..f8f848cb9 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, diff --git a/src/server/connection.rs b/src/server/connection.rs index 1259054cd..6741de59e 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1813,6 +1813,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..dc1cda888 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -245,7 +245,39 @@ 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()); + let value = normalize_local_option_value(&key, value); + LocalConfig::set_option(key.clone(), value); +} + +fn normalize_local_option_value(key: &str, value: String) -> String { + if key != "user_info" || value.is_empty() { + return value; + } + let Ok(mut v) = serde_json::from_str::(&value) else { + return value; + }; + let Some(obj) = v.as_object_mut() else { + return value; + }; + let Some(avatar) = obj + .get("avatar") + .and_then(|x| x.as_str()) + .map(|x| x.trim().to_owned()) + else { + return value; + }; + if !avatar.starts_with('/') { + return value; + } + let api_server = get_api_server(); + if api_server.is_empty() { + return value; + } + obj.insert( + "avatar".to_owned(), + serde_json::Value::String(format!("{}{}", api_server.trim_end_matches('/'), avatar)), + ); + serde_json::to_string(&v).unwrap_or(value) } #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]