Compare commits

...

3 Commits

Author SHA1 Message Date
21pages
743705545a increase ipc Data enum size limit to 120 bytes
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-04 18:28:39 +08:00
21pages
89a34642a3 web: implement mainResolveAvatarUrl via js getByName
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-04 18:17:05 +08:00
21pages
d9e2107fb8 refactor avatar display: unify rendering and resolve at use time
- Extract buildAvatarWidget() in common.dart to share avatar rendering
    logic across desktop settings, desktop CM and mobile CM
  - Add resolve_avatar_url() in Rust, exposed via FFI (SyncReturn),
    to resolve relative avatar paths (e.g. "/avatar/xxx") to absolute URLs
  - Store avatar as-is in local config, only resolve when displaying
    (settings page) or sending (LoginRequest)
  - Resolve avatar in LoginRequest before sending to remote peer
  - Add error handling for network image load failures
  - Guard against empty client.name[0] crash
  - Show avatar in mobile settings page account tile

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-04 15:58:20 +08:00
12 changed files with 95 additions and 109 deletions

View File

@@ -4118,3 +4118,43 @@ String mouseButtonsToPeer(int buttons) {
return ''; 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(),
),
);
}

View File

@@ -2061,7 +2061,8 @@ class _AccountState extends State<_Account> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: Theme.of(context).textTheme.bodySmall?.color, color:
Theme.of(context).textTheme.bodySmall?.color,
), ),
), ),
), ),
@@ -2076,28 +2077,13 @@ class _AccountState extends State<_Account> {
} }
Widget? _buildUserAvatar() { Widget? _buildUserAvatar() {
final avatar = gFFI.userModel.avatar.value.trim(); // Resolve relative avatar path at display time
if (avatar.isEmpty) return null; final avatar =
const radius = 22.0; bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
if (avatar.startsWith('data:image/')) { return buildAvatarWidget(
final comma = avatar.indexOf(','); avatar: avatar,
if (comma > 0) { size: 44,
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;
} }
} }

View File

@@ -1,7 +1,6 @@
// 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';
@@ -569,37 +568,12 @@ class _CmHeaderState extends State<_CmHeader>
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
Widget _buildClientAvatar() { Widget _buildClientAvatar() {
const borderRadius = BorderRadius.all(Radius.circular(15.0)); return buildAvatarWidget(
final avatar = client.avatar.trim(); avatar: client.avatar,
if (avatar.startsWith('data:image/')) { size: 70,
final comma = avatar.indexOf(','); borderRadius: 15,
if (comma > 0) { fallback: _buildInitialAvatar(),
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() { Widget _buildInitialAvatar() {
@@ -612,7 +586,7 @@ class _CmHeaderState extends State<_CmHeader>
borderRadius: BorderRadius.circular(15.0), borderRadius: BorderRadius.circular(15.0),
), ),
child: Text( child: Text(
client.name[0], client.name.isNotEmpty ? client.name[0] : '?',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,

View File

@@ -1,5 +1,4 @@
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';
@@ -857,28 +856,17 @@ class ClientInfo extends StatelessWidget {
} }
Widget _buildAvatar(BuildContext context) { Widget _buildAvatar(BuildContext context) {
final avatar = client.avatar.trim(); final fallback = CircleAvatar(
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( backgroundColor: str2color(
client.name, client.name,
Theme.of(context).brightness == Brightness.light ? 255 : 150), Theme.of(context).brightness == Brightness.light ? 255 : 150),
child: Text(client.name[0]), child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
); );
return buildAvatarWidget(
avatar: client.avatar,
size: 40,
fallback: fallback,
)!;
} }
} }

View File

@@ -689,7 +689,15 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
? translate('Login') ? translate('Login')
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')), : '${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) { onPressed: (context) {
if (gFFI.userModel.userName.value.isEmpty) { if (gFFI.userModel.userName.value.isEmpty) {
loginDialog(); loginDialog();

View File

@@ -34,6 +34,7 @@ class UserModel {
} }
return '$preferred (@$username)'; return '$preferred (@$username)';
} }
WeakReference<FFI> parent; WeakReference<FFI> parent;
UserModel(this.parent) { UserModel(this.parent) {
@@ -88,7 +89,6 @@ 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');
@@ -138,7 +138,6 @@ class UserModel {
avatar.value = user.avatar; 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

@@ -2034,5 +2034,9 @@ class RustdeskImpl {
return false; return false;
} }
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
}
void dispose() {} void dispose() {}
} }

View File

@@ -33,7 +33,7 @@ use crate::{
create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported,
kcp_stream::KcpStream, kcp_stream::KcpStream,
secure_tcp, 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}, ui_session_interface::{InvokeUiSession, Session},
}; };
#[cfg(feature = "unix-file-copy-paste")] #[cfg(feature = "unix-file-copy-paste")]
@@ -2638,6 +2638,7 @@ impl LoginConfigHandler {
}) })
.unwrap_or_default(); .unwrap_or_default();
} }
avatar = resolve_avatar_url(avatar);
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 =

View File

@@ -1101,6 +1101,10 @@ pub fn main_get_api_server() -> String {
get_api_server() get_api_server()
} }
pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn<String> {
SyncReturn(resolve_avatar_url(avatar))
}
pub fn main_http_request(url: String, method: String, body: Option<String>, header: String) { pub fn main_http_request(url: String, method: String, body: Option<String>, header: String) {
http_request(url, method, body, header) http_request(url, method, body, header)
} }

View File

@@ -17,6 +17,7 @@ lazy_static::lazy_static! {
const QUERY_INTERVAL_SECS: f32 = 1.0; const QUERY_INTERVAL_SECS: f32 = 1.0;
const QUERY_TIMEOUT_SECS: u64 = 60 * 3; const QUERY_TIMEOUT_SECS: u64 = 60 * 3;
const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth"; const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth";
const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth"; const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth";
const LOGIN_ACCOUNT_AUTH: &str = "Login account auth"; const LOGIN_ACCOUNT_AUTH: &str = "Login account auth";

View File

@@ -1584,6 +1584,6 @@ mod test {
#[test] #[test]
fn verify_ffi_enum_data_size() { fn verify_ffi_enum_data_size() {
println!("{}", std::mem::size_of::<Data>()); println!("{}", std::mem::size_of::<Data>());
assert!(std::mem::size_of::<Data>() <= 96); assert!(std::mem::size_of::<Data>() <= 120);
} }
} }

View File

@@ -245,39 +245,20 @@ 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) {
let value = normalize_local_option_value(&key, value);
LocalConfig::set_option(key.clone(), value); LocalConfig::set_option(key.clone(), value);
} }
fn normalize_local_option_value(key: &str, value: String) -> String { /// Resolve relative avatar path (e.g. "/avatar/xxx") to absolute URL
if key != "user_info" || value.is_empty() { /// by prepending the API server address.
return value; 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);
}
} }
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&value) else { avatar
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"))]