Compare commits

...

1 Commits

Author SHA1 Message Date
rustdesk
890282e385 avatar 2026-03-02 12:27:21 +08:00
16 changed files with 236 additions and 46 deletions

View File

@@ -26,6 +26,7 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
class UserPayload { class UserPayload {
String name = ''; String name = '';
String displayName = ''; String displayName = '';
String avatar = '';
String email = ''; String email = '';
String note = ''; String note = '';
String? verifier; String? verifier;
@@ -35,6 +36,7 @@ class UserPayload {
UserPayload.fromJson(Map<String, dynamic> json) UserPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '', : name = json['name'] ?? '',
displayName = json['display_name'] ?? '', displayName = json['display_name'] ?? '',
avatar = json['avatar'] ?? '',
email = json['email'] ?? '', email = json['email'] ?? '',
note = json['note'] ?? '', note = json['note'] ?? '',
verifier = json['verifier'], verifier = json['verifier'],
@@ -49,6 +51,7 @@ class UserPayload {
final Map<String, dynamic> map = { final Map<String, dynamic> map = {
'name': name, 'name': name,
'display_name': displayName, 'display_name': displayName,
'avatar': avatar,
'status': status == UserStatus.kDisabled 'status': status == UserStatus.kDisabled
? 0 ? 0
: status == UserStatus.kUnverified : status == UserStatus.kUnverified

View File

@@ -2026,28 +2026,79 @@ class _AccountState extends State<_Account> {
} }
Widget useInfo() { 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( return Obx(() => Offstage(
offstage: gFFI.userModel.userName.value.isEmpty, offstage: gFFI.userModel.userName.value.isEmpty,
child: Column( child: Container(
children: [ padding: const EdgeInsets.all(12),
if (gFFI.userModel.displayName.value.trim().isNotEmpty && decoration: BoxDecoration(
gFFI.userModel.displayName.value.trim() != color: Theme.of(context).colorScheme.surfaceContainerHighest,
gFFI.userModel.userName.value.trim()) borderRadius: BorderRadius.circular(10),
text('Display Name', gFFI.userModel.displayName.value.trim()), ),
text('Username', gFFI.userModel.userName.value), child: Builder(builder: (context) {
// text('Group', gFFI.groupModel.groupName.value), 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); )).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 { class _Checkbox extends StatefulWidget {

View File

@@ -1,6 +1,7 @@
// original cm window in Sciter version. // original cm window in Sciter version.
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -462,23 +463,7 @@ class _CmHeaderState extends State<_CmHeader>
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( _buildClientAvatar().marginOnly(right: 10.0),
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),
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@@ -582,6 +567,60 @@ class _CmHeaderState extends State<_CmHeader>
@override @override
bool get wantKeepAlive => true; 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 { class _PrivilegeBoard extends StatefulWidget {

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -841,13 +842,7 @@ class ClientInfo extends StatelessWidget {
flex: -1, flex: -1,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: CircleAvatar( child: _buildAvatar(context))),
backgroundColor: str2color(
client.name,
Theme.of(context).brightness == Brightness.light
? 255
: 150),
child: Text(client.name[0])))),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, 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() { void androidChannelInit() {

View File

@@ -820,6 +820,7 @@ class Client {
bool isTerminal = false; bool isTerminal = false;
String portForward = ""; String portForward = "";
String name = ""; String name = "";
String avatar = "";
String peerId = ""; // peer user's id,show at app String peerId = ""; // peer user's id,show at app
bool keyboard = false; bool keyboard = false;
bool clipboard = false; bool clipboard = false;
@@ -847,6 +848,7 @@ class Client {
isTerminal = json['is_terminal'] ?? false; isTerminal = json['is_terminal'] ?? false;
portForward = json['port_forward']; portForward = json['port_forward'];
name = json['name']; name = json['name'];
avatar = json['avatar'] ?? '';
peerId = json['peer_id']; peerId = json['peer_id'];
keyboard = json['keyboard']; keyboard = json['keyboard'];
clipboard = json['clipboard']; clipboard = json['clipboard'];
@@ -870,6 +872,7 @@ class Client {
data['is_terminal'] = isTerminal; data['is_terminal'] = isTerminal;
data['port_forward'] = portForward; data['port_forward'] = portForward;
data['name'] = name; data['name'] = name;
data['avatar'] = avatar;
data['peer_id'] = peerId; data['peer_id'] = peerId;
data['keyboard'] = keyboard; data['keyboard'] = keyboard;
data['clipboard'] = clipboard; data['clipboard'] = clipboard;

View File

@@ -17,6 +17,7 @@ bool refreshingUser = false;
class UserModel { class UserModel {
final RxString userName = ''.obs; final RxString userName = ''.obs;
final RxString displayName = ''.obs; final RxString displayName = ''.obs;
final RxString avatar = ''.obs;
final RxBool isAdmin = false.obs; final RxBool isAdmin = false.obs;
final RxString networkError = ''.obs; final RxString networkError = ''.obs;
bool get isLogin => userName.isNotEmpty; bool get isLogin => userName.isNotEmpty;
@@ -87,6 +88,7 @@ class UserModel {
} }
final user = UserPayload.fromJson(data); final user = UserPayload.fromJson(data);
user.avatar = _resolveAvatar(user.avatar, url);
_parseAndUpdateUser(user); _parseAndUpdateUser(user);
} catch (e) { } catch (e) {
debugPrint('Failed to refreshCurrentUser: $e'); debugPrint('Failed to refreshCurrentUser: $e');
@@ -114,6 +116,7 @@ class UserModel {
if (userInfo != null) { if (userInfo != null) {
userName.value = (userInfo['name'] ?? '').toString(); userName.value = (userInfo['name'] ?? '').toString();
displayName.value = (userInfo['display_name'] ?? '').toString(); displayName.value = (userInfo['display_name'] ?? '').toString();
avatar.value = (userInfo['avatar'] ?? '').toString();
} }
} }
@@ -126,13 +129,16 @@ class UserModel {
} }
userName.value = ''; userName.value = '';
displayName.value = ''; displayName.value = '';
avatar.value = '';
} }
_parseAndUpdateUser(UserPayload user) { _parseAndUpdateUser(UserPayload user) {
userName.value = user.name; userName.value = user.name;
displayName.value = user.displayName; displayName.value = user.displayName;
avatar.value = user.avatar;
isAdmin.value = user.isAdmin; isAdmin.value = user.isAdmin;
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user)); bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
_updateLocalUserInfo();
if (isWeb) { if (isWeb) {
// ugly here, tmp solution // ugly here, tmp solution
bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? ''); bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? '');

View File

@@ -2625,6 +2625,19 @@ impl LoginConfigHandler {
} else { } else {
(my_id, self.id.clone()) (my_id, self.id.clone())
}; };
let mut avatar = get_builtin_option(keys::OPTION_AVATAR);
if avatar.is_empty() {
avatar = serde_json::from_str::<serde_json::Value>(&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); let mut display_name = get_builtin_option(keys::OPTION_DISPLAY_NAME);
if display_name.is_empty() { if display_name.is_empty() {
display_name = display_name =
@@ -2684,6 +2697,7 @@ impl LoginConfigHandler {
}) })
.into(), .into(),
hwid, hwid,
avatar,
..Default::default() ..Default::default()
}; };
match self.conn_type { match self.conn_type {

View File

@@ -82,6 +82,8 @@ pub struct UserPayload {
#[serde(default)] #[serde(default)]
pub display_name: Option<String>, pub display_name: Option<String>,
#[serde(default)] #[serde(default)]
pub avatar: Option<String>,
#[serde(default)]
pub email: Option<String>, pub email: Option<String>,
#[serde(default)] #[serde(default)]
pub note: Option<String>, pub note: Option<String>,
@@ -273,6 +275,7 @@ impl OidcSession {
serde_json::json!({ serde_json::json!({
"name": auth_body.user.name, "name": auth_body.user.name,
"display_name": auth_body.user.display_name, "display_name": auth_body.user.display_name,
"avatar": auth_body.user.avatar,
"status": auth_body.user.status "status": auth_body.user.status
}) })
.to_string(), .to_string(),

View File

@@ -226,6 +226,7 @@ pub enum Data {
is_terminal: bool, is_terminal: bool,
peer_id: String, peer_id: String,
name: String, name: String,
avatar: String,
authorized: bool, authorized: bool,
port_forward: String, port_forward: String,
keyboard: bool, keyboard: bool,

View File

@@ -1813,6 +1813,7 @@ impl Connection {
port_forward: self.port_forward_address.clone(), port_forward: self.port_forward_address.clone(),
peer_id, peer_id,
name, name,
avatar: self.lr.avatar.clone(),
authorized, authorized,
keyboard: self.keyboard, keyboard: self.keyboard,
clipboard: self.clipboard, clipboard: self.clipboard,

View File

@@ -57,6 +57,11 @@ div.icon {
font-weight: bold; font-weight: bold;
} }
img.icon {
size: 96px;
border-radius: 8px;
}
div.id { div.id {
@ELLIPSIS; @ELLIPSIS;
color: color(green-blue); color: color(green-blue);

View File

@@ -28,6 +28,7 @@ impl InvokeUiCM for SciterHandler {
client.port_forward.clone(), client.port_forward.clone(),
client.peer_id.clone(), client.peer_id.clone(),
client.name.clone(), client.name.clone(),
client.avatar.clone(),
client.authorized, client.authorized,
client.keyboard, client.keyboard,
client.clipboard, client.clipboard,

View File

@@ -42,9 +42,11 @@ class Body: Reactor.Component
return <div .content style="size:*"> return <div .content style="size:*">
<div .left-panel> <div .left-panel>
<div .icon-and-id> <div .icon-and-id>
{c.avatar ?
<img .icon src={c.avatar} /> :
<div .icon style={"background: " + string2RGB(c.name, 1)}> <div .icon style={"background: " + string2RGB(c.name, 1)}>
{c.name[0].toUpperCase()} {c.name[0].toUpperCase()}
</div> </div>}
<div> <div>
<div .id style="font-weight: bold; font-size: 1.2em;">{c.name}</div> <div .id style="font-weight: bold; font-size: 1.2em;">{c.name}</div>
<div .id>({c.peer_id})</div> <div .id>({c.peer_id})</div>
@@ -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); stdout.println("new connection #" + id + ": " + peer_id);
var conn; var conn;
connections.map(function(c) { connections.map(function(c) {
@@ -385,6 +387,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin
conn = { conn = {
id: id, is_file_transfer: is_file_transfer, is_view_camera: is_view_camera, is_terminal: is_terminal, peer_id: peer_id, 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, port_forward: port_forward,
avatar: avatar,
name: name, authorized: authorized, time: new Date(), now: new Date(), name: name, authorized: authorized, time: new Date(), now: new Date(),
keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0, keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0,
audio: audio, file: file, restart: restart, recording: recording, audio: audio, file: file, restart: restart, recording: recording,

View File

@@ -1451,6 +1451,9 @@ function set_local_user_info(user) {
if (user.display_name) { if (user.display_name) {
user_info.display_name = user.display_name; user_info.display_name = user.display_name;
} }
if (user.avatar) {
user_info.avatar = user.avatar;
}
if (user.status) { if (user.status) {
user_info.status = user.status; user_info.status = user.status;
} }

View File

@@ -134,6 +134,7 @@ pub struct Client {
pub is_terminal: bool, pub is_terminal: bool,
pub port_forward: String, pub port_forward: String,
pub name: String, pub name: String,
pub avatar: String,
pub peer_id: String, pub peer_id: String,
pub keyboard: bool, pub keyboard: bool,
pub clipboard: bool, pub clipboard: bool,
@@ -220,6 +221,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
port_forward: String, port_forward: String,
peer_id: String, peer_id: String,
name: String, name: String,
avatar: String,
authorized: bool, authorized: bool,
keyboard: bool, keyboard: bool,
clipboard: bool, clipboard: bool,
@@ -240,6 +242,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
is_terminal, is_terminal,
port_forward, port_forward,
name: name.clone(), name: name.clone(),
avatar,
peer_id: peer_id.clone(), peer_id: peer_id.clone(),
keyboard, keyboard,
clipboard, clipboard,
@@ -500,9 +503,9 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
} }
Ok(Some(data)) => { Ok(Some(data)) => {
match 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); 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; self.conn_id = id;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -823,6 +826,7 @@ pub async fn start_listen<T: InvokeUiCM>(
port_forward, port_forward,
peer_id, peer_id,
name, name,
avatar,
authorized, authorized,
keyboard, keyboard,
clipboard, clipboard,
@@ -843,6 +847,7 @@ pub async fn start_listen<T: InvokeUiCM>(
port_forward, port_forward,
peer_id, peer_id,
name, name,
avatar,
authorized, authorized,
keyboard, keyboard,
clipboard, clipboard,

View File

@@ -245,7 +245,39 @@ pub fn get_builtin_option(key: &str) -> String {
#[inline] #[inline]
pub fn set_local_option(key: String, value: String) { 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::<serde_json::Value>(&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"))] #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]