mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-07-11 09:45:11 +03:00
Compare commits
7 Commits
cd2fff0655
...
cjk_armlin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d934318b | ||
|
|
2747d3d8b4 | ||
|
|
3c574a4182 | ||
|
|
311d4708e5 | ||
|
|
5cf4323d07 | ||
|
|
3976701ac6 | ||
|
|
9ded8d6ab2 |
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -1324,7 +1324,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-master"
|
name = "clipboard-master"
|
||||||
version = "4.0.0-beta.6"
|
version = "4.0.0-beta.6"
|
||||||
source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809"
|
source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc",
|
"objc",
|
||||||
"objc-foundation",
|
"objc-foundation",
|
||||||
@@ -6920,7 +6920,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "rdev"
|
name = "rdev"
|
||||||
version = "0.5.0-2"
|
version = "0.5.0-2"
|
||||||
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8"
|
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cocoa 0.24.1",
|
"cocoa 0.24.1",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
@@ -9733,9 +9733,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols-wlr"
|
name = "wayland-protocols-wlr"
|
||||||
version = "0.3.3"
|
version = "0.3.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953"
|
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.1",
|
"bitflags 2.9.1",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -10838,16 +10838,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wl-clipboard-rs"
|
name = "wl-clipboard-rs"
|
||||||
version = "0.9.0"
|
version = "0.9.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f"
|
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"os_pipe",
|
"os_pipe",
|
||||||
"rustix 0.38.34",
|
"rustix 1.1.2",
|
||||||
"tempfile",
|
"thiserror 2.0.17",
|
||||||
"thiserror 1.0.61",
|
|
||||||
"tree_magic_mini",
|
"tree_magic_mini",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
|||||||
@@ -598,22 +598,6 @@ class MyTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies [fallbacks] as fontFamilyFallback to every text style in both
|
|
||||||
/// themes. Called once at startup on ARM64 Linux after a CJK font has been
|
|
||||||
/// loaded via FontLoader (see flutter/flutter#139293).
|
|
||||||
static void applyFontFallback(List<String> fallbacks) {
|
|
||||||
lightTheme = lightTheme.copyWith(
|
|
||||||
textTheme: lightTheme.textTheme.apply(fontFamilyFallback: fallbacks),
|
|
||||||
primaryTextTheme:
|
|
||||||
lightTheme.primaryTextTheme.apply(fontFamilyFallback: fallbacks),
|
|
||||||
);
|
|
||||||
darkTheme = darkTheme.copyWith(
|
|
||||||
textTheme: darkTheme.textTheme.apply(fontFamilyFallback: fallbacks),
|
|
||||||
primaryTextTheme:
|
|
||||||
darkTheme.primaryTextTheme.apply(fontFamilyFallback: fallbacks),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ThemeMode currentThemeMode() {
|
static ThemeMode currentThemeMode() {
|
||||||
final preference = getThemeModePreference();
|
final preference = getThemeModePreference();
|
||||||
if (preference == ThemeMode.system) {
|
if (preference == ThemeMode.system) {
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ import 'mobile/pages/home_page.dart';
|
|||||||
import 'mobile/pages/server_page.dart';
|
import 'mobile/pages/server_page.dart';
|
||||||
import 'mobile/widgets/deploy_dialog.dart';
|
import 'mobile/widgets/deploy_dialog.dart';
|
||||||
import 'models/platform_model.dart';
|
import 'models/platform_model.dart';
|
||||||
import 'native/font_manager.dart'
|
|
||||||
if (dart.library.html) 'web/font_manager.dart';
|
|
||||||
|
|
||||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
|
||||||
@@ -39,15 +37,10 @@ import 'package:flutter_hbb/plugin/handlers.dart'
|
|||||||
int? kWindowId;
|
int? kWindowId;
|
||||||
WindowType? kWindowType;
|
WindowType? kWindowType;
|
||||||
late List<String> kBootArgs;
|
late List<String> kBootArgs;
|
||||||
bool _cjkFontLoaded = false;
|
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
earlyAssert();
|
earlyAssert();
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
_cjkFontLoaded = await loadSystemCJKFonts();
|
|
||||||
if (_cjkFontLoaded) {
|
|
||||||
MyTheme.applyFontFallback([kLinuxCjkFontFamily]);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint("launch args: $args");
|
debugPrint("launch args: $args");
|
||||||
kBootArgs = List.from(args);
|
kBootArgs = List.from(args);
|
||||||
@@ -390,7 +383,6 @@ void _runApp(
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
child = _keepScaleBuilder(context, child);
|
child = _keepScaleBuilder(context, child);
|
||||||
child = botToastBuilder(context, child);
|
child = botToastBuilder(context, child);
|
||||||
if (_cjkFontLoaded) child = _mergeCjkFallback(context, child);
|
|
||||||
return child;
|
return child;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -541,7 +533,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
: (context, child) {
|
: (context, child) {
|
||||||
child = _keepScaleBuilder(context, child);
|
child = _keepScaleBuilder(context, child);
|
||||||
child = botToastBuilder(context, child);
|
child = botToastBuilder(context, child);
|
||||||
if (_cjkFontLoaded) child = _mergeCjkFallback(context, child);
|
|
||||||
if ((isDesktop && desktopType == DesktopType.main) ||
|
if ((isDesktop && desktopType == DesktopType.main) ||
|
||||||
isWebDesktop) {
|
isWebDesktop) {
|
||||||
child = keyListenerBuilder(context, child);
|
child = keyListenerBuilder(context, child);
|
||||||
@@ -595,19 +586,6 @@ _registerEventHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merges the theme's fontFamilyFallback into [DefaultTextStyle] so that
|
|
||||||
/// bare [Text] widgets (and those with inherit:true styles) also pick up the
|
|
||||||
/// CJK fallback font loaded on ARM64 Linux.
|
|
||||||
Widget _mergeCjkFallback(BuildContext context, Widget? child) {
|
|
||||||
final result = child ?? Container();
|
|
||||||
final fallback = Theme.of(context).textTheme.bodyMedium?.fontFamilyFallback;
|
|
||||||
if (fallback == null || fallback.isEmpty) return result;
|
|
||||||
return DefaultTextStyle.merge(
|
|
||||||
style: TextStyle(fontFamilyFallback: fallback),
|
|
||||||
child: result,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||||
return RawKeyboardListener(
|
return RawKeyboardListener(
|
||||||
focusNode: FocusNode(),
|
focusNode: FocusNode(),
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import 'dart:ffi' show Abi;
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
/// Font family name registered with [FontLoader] when a system CJK font is
|
|
||||||
/// successfully loaded on ARM64 Linux.
|
|
||||||
const kLinuxCjkFontFamily = 'SystemCJK';
|
|
||||||
|
|
||||||
const _kFontSearchPaths = [
|
|
||||||
// Debian / Ubuntu (noto-fonts / fonts-noto-cjk)
|
|
||||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
|
||||||
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
|
||||||
'/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf',
|
|
||||||
// Fedora / RHEL / Rocky (google-noto-sans-cjk-fonts)
|
|
||||||
'/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc',
|
|
||||||
'/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc',
|
|
||||||
// Arch Linux (noto-fonts-cjk)
|
|
||||||
'/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
|
|
||||||
'/usr/share/fonts/noto-cjk/NotoSansCJKsc-Regular.otf',
|
|
||||||
// Generic fallback paths
|
|
||||||
'/usr/share/fonts/noto/NotoSansCJK-Regular.ttc',
|
|
||||||
'/usr/share/fonts/noto/NotoSansCJKsc-Regular.otf',
|
|
||||||
// WenQuanYi — commonly pre-installed on CJK-locale systems
|
|
||||||
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
|
||||||
'/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
|
|
||||||
'/usr/share/fonts/wqy-microhei/wqy-microhei.ttc',
|
|
||||||
'/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc',
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Loads a system CJK font on ARM64 Linux into Flutter's font registry via
|
|
||||||
/// [FontLoader], working around the missing fontconfig support in the
|
|
||||||
/// flutter-elinux engine (https://github.com/flutter/flutter/issues/139293).
|
|
||||||
///
|
|
||||||
/// Returns true if a CJK font was successfully loaded; false otherwise.
|
|
||||||
/// On all other platforms this is a no-op and returns false immediately.
|
|
||||||
Future<bool> loadSystemCJKFonts() async {
|
|
||||||
if (Abi.current() != Abi.linuxArm64) return false;
|
|
||||||
|
|
||||||
final path = await _findCjkFontPath();
|
|
||||||
if (path == null) {
|
|
||||||
debugPrint('ARM64 Linux: no CJK font found; CJK text may not render');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final loader = FontLoader(kLinuxCjkFontFamily);
|
|
||||||
final bytes = await File(path).readAsBytes();
|
|
||||||
loader.addFont(Future.value(ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)));
|
|
||||||
await loader.load();
|
|
||||||
debugPrint('ARM64 Linux: loaded CJK font from $path');
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('ARM64 Linux: failed to load CJK font: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _findCjkFontPath() async {
|
|
||||||
// Query fc-list for each CJK script separately. Fonts present in all three
|
|
||||||
// sets (zh ∩ ja ∩ ko) are true pan-CJK fonts; prefer them so we don't
|
|
||||||
// accidentally pick a Chinese-only font that lacks Japanese kana or Korean
|
|
||||||
// hangul glyphs. fc-list is a fontconfig CLI tool available on most Linux
|
|
||||||
// systems independent of whether the Flutter engine was built with fontconfig.
|
|
||||||
final byLang = <String, Set<String>>{};
|
|
||||||
for (final lang in const ['zh', 'ja', 'ko']) {
|
|
||||||
final paths = <String>{};
|
|
||||||
try {
|
|
||||||
final r =
|
|
||||||
await Process.run('fc-list', [':lang=$lang', '--format=%{file}\n']);
|
|
||||||
if (r.exitCode == 0) {
|
|
||||||
for (final line in r.stdout.toString().split('\n')) {
|
|
||||||
final p = line.trim();
|
|
||||||
if (p.isNotEmpty && File(p).existsSync()) paths.add(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('ARM64 Linux: fc-list failed for lang=$lang: $e');
|
|
||||||
}
|
|
||||||
byLang[lang] = paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
final panCjk = byLang['zh']!
|
|
||||||
.intersection(byLang['ja']!)
|
|
||||||
.intersection(byLang['ko']!);
|
|
||||||
final anyCjk =
|
|
||||||
byLang.values.fold(<String>{}, (acc, s) => acc..addAll(s));
|
|
||||||
|
|
||||||
// Among candidates, prefer well-known pan-CJK font families.
|
|
||||||
String? pick(Iterable<String> pool) {
|
|
||||||
const preferred = ['notosanscjk', 'sourcehansans', 'sourcehanserif'];
|
|
||||||
for (final name in preferred) {
|
|
||||||
for (final p in pool) {
|
|
||||||
if (p.toLowerCase().contains(name)) return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pool.isNotEmpty ? pool.first : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final found = pick(panCjk) ?? pick(anyCjk);
|
|
||||||
if (found != null) return found;
|
|
||||||
|
|
||||||
for (final p in _kFontSearchPaths) {
|
|
||||||
if (File(p).existsSync()) return p;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/// Web stub for `native/font_manager.dart`.
|
|
||||||
///
|
|
||||||
/// The native implementation depends on `dart:io` (Process/File/Platform) to
|
|
||||||
/// load a system CJK font on ARM64 Linux, which cannot compile for the web
|
|
||||||
/// target. The web build has no such fontconfig limitation, so this is a no-op.
|
|
||||||
const kLinuxCjkFontFamily = 'SystemCJK';
|
|
||||||
|
|
||||||
Future<bool> loadSystemCJKFonts() async => false;
|
|
||||||
@@ -868,6 +868,7 @@ pub mod clipboard_listener {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(name.clone(), tx);
|
.insert(name.clone(), tx);
|
||||||
|
|
||||||
|
cleanup_stale_listener(&mut listener_lock);
|
||||||
if listener_lock.handle.is_none() {
|
if listener_lock.handle.is_none() {
|
||||||
log::info!("Start clipboard listener thread");
|
log::info!("Start clipboard listener thread");
|
||||||
let handler = Handler {
|
let handler = Handler {
|
||||||
@@ -893,6 +894,24 @@ pub mod clipboard_listener {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cleanup_stale_listener(listener: &mut ClipboardListener) {
|
||||||
|
if !listener
|
||||||
|
.handle
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, h)| h.is_finished())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((shutdown, h)) = listener.handle.take() {
|
||||||
|
log::warn!("Cleaning up stale clipboard listener handle");
|
||||||
|
if let Err(e) = h.join() {
|
||||||
|
log::error!("Clipboard listener thread panicked during stale cleanup: {:?}", e);
|
||||||
|
}
|
||||||
|
drop(shutdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unsubscribe(name: &str) {
|
pub fn unsubscribe(name: &str) {
|
||||||
log::info!("Unsubscribe clipboard listener: {}", name);
|
log::info!("Unsubscribe clipboard listener: {}", name);
|
||||||
let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap();
|
let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap();
|
||||||
|
|||||||
@@ -1245,11 +1245,49 @@ pub fn legacy_keyboard_mode(event: &Event, mut key_event: KeyEvent) -> Vec<KeyEv
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn map_keyboard_mode(_peer: &str, event: &Event, key_event: KeyEvent) -> Vec<KeyEvent> {
|
pub fn map_keyboard_mode(_peer: &str, event: &Event, key_event: KeyEvent) -> Vec<KeyEvent> {
|
||||||
|
if let Some(evt) = windows_peer_special_key(_peer, event) {
|
||||||
|
return vec![evt];
|
||||||
|
}
|
||||||
|
|
||||||
_map_keyboard_mode(_peer, event, key_event)
|
_map_keyboard_mode(_peer, event, key_event)
|
||||||
.map(|e| vec![e])
|
.map(|e| vec![e])
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn windows_peer_special_key(peer: &str, event: &Event) -> Option<KeyEvent> {
|
||||||
|
if peer != OS_LOWER_WINDOWS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (key, down) = match event.event_type {
|
||||||
|
EventType::KeyPress(key) => (key, true),
|
||||||
|
EventType::KeyRelease(key) => (key, false),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle only `Pause` for Windows peers for now.
|
||||||
|
// Windows has no normal scan code for `Pause`, so send it as a legacy control key.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let is_pause = {
|
||||||
|
// The Windows scan code can look like `NumLock`; VK_PAUSE distinguishes it.
|
||||||
|
let pause_vk_code = rdev::win_code_from_key(Key::Pause);
|
||||||
|
key == Key::Pause || pause_vk_code == Some(event.platform_code as _)
|
||||||
|
};
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let is_pause = key == Key::Pause;
|
||||||
|
if !is_pause {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key_event = KeyEvent::new();
|
||||||
|
key_event.mode = KeyboardMode::Legacy.into();
|
||||||
|
key_event.down = down;
|
||||||
|
key_event.set_control_key(ControlKey::Pause);
|
||||||
|
let (alt, ctrl, shift, command) = client::get_modifiers_state(false, false, false, false);
|
||||||
|
client::legacy_modifiers(&mut key_event, alt, ctrl, shift, command);
|
||||||
|
Some(key_event)
|
||||||
|
}
|
||||||
|
|
||||||
fn _map_keyboard_mode(_peer: &str, event: &Event, mut key_event: KeyEvent) -> Option<KeyEvent> {
|
fn _map_keyboard_mode(_peer: &str, event: &Event, mut key_event: KeyEvent) -> Option<KeyEvent> {
|
||||||
match event.event_type {
|
match event.event_type {
|
||||||
EventType::KeyPress(..) => {
|
EventType::KeyPress(..) => {
|
||||||
@@ -1421,6 +1459,11 @@ fn is_press(event: &Event) -> bool {
|
|||||||
pub fn translate_keyboard_mode(peer: &str, event: &Event, key_event: KeyEvent) -> Vec<KeyEvent> {
|
pub fn translate_keyboard_mode(peer: &str, event: &Event, key_event: KeyEvent) -> Vec<KeyEvent> {
|
||||||
let mut events: Vec<KeyEvent> = Vec::new();
|
let mut events: Vec<KeyEvent> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(evt) = windows_peer_special_key(peer, event) {
|
||||||
|
events.push(evt);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(unicode_info) = &event.unicode {
|
if let Some(unicode_info) = &event.unicode {
|
||||||
if unicode_info.is_dead {
|
if unicode_info.is_dead {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
74
src/lang.rs
74
src/lang.rs
@@ -103,15 +103,29 @@ pub const LANGS: &[(&str, &str)] = &[
|
|||||||
("gu", "ગુજરાતી"),
|
("gu", "ગુજરાતી"),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
pub(crate) fn cjk_ui_unavailable() -> bool {
|
||||||
pub fn translate(name: String) -> String {
|
cfg!(all(
|
||||||
let locale = sys_locale::get_locale().unwrap_or_default();
|
target_os = "linux",
|
||||||
translate_locale(name, &locale)
|
target_arch = "aarch64",
|
||||||
|
feature = "flutter"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn translate_locale(name: String, locale: &str) -> String {
|
pub(crate) fn is_cjk_lang(lang_or_locale: &str) -> bool {
|
||||||
|
let lang = lang_or_locale
|
||||||
|
.split(|c| c == '-' || c == '_')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
matches!(lang.as_str(), "zh" | "ja" | "ko")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_lang(saved_lang: &str, locale: &str, cjk_fallback: bool) -> String {
|
||||||
let locale = locale.to_lowercase();
|
let locale = locale.to_lowercase();
|
||||||
let mut lang = hbb_common::config::LocalConfig::get_option("lang").to_lowercase();
|
let mut lang = saved_lang.to_lowercase();
|
||||||
|
if cjk_fallback && is_cjk_lang(&lang) {
|
||||||
|
return "en".to_owned();
|
||||||
|
}
|
||||||
if lang.is_empty() {
|
if lang.is_empty() {
|
||||||
// zh_CN on Linux, zh-Hans-CN on mac, zh_CN_#Hans on Android
|
// zh_CN on Linux, zh-Hans-CN on mac, zh_CN_#Hans on Android
|
||||||
if locale.starts_with("zh") {
|
if locale.starts_with("zh") {
|
||||||
@@ -131,7 +145,25 @@ pub fn translate_locale(name: String, locale: &str) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_owned();
|
.to_owned();
|
||||||
}
|
}
|
||||||
let lang = lang.to_lowercase();
|
if cjk_fallback && is_cjk_lang(&lang) {
|
||||||
|
"en".to_owned()
|
||||||
|
} else {
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
pub fn translate(name: String) -> String {
|
||||||
|
let locale = sys_locale::get_locale().unwrap_or_default();
|
||||||
|
translate_locale(name, &locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translate_locale(name: String, locale: &str) -> String {
|
||||||
|
let lang = resolve_lang(
|
||||||
|
&hbb_common::config::LocalConfig::get_option("lang"),
|
||||||
|
locale,
|
||||||
|
cjk_ui_unavailable(),
|
||||||
|
);
|
||||||
let m = match lang.as_str() {
|
let m = match lang.as_str() {
|
||||||
"fr" => fr::T.deref(),
|
"fr" => fr::T.deref(),
|
||||||
"zh-cn" => cn::T.deref(),
|
"zh-cn" => cn::T.deref(),
|
||||||
@@ -275,4 +307,32 @@ mod test {
|
|||||||
("{} times {4} makes {8}".to_string(), Some("2".to_string()))
|
("{} times {4} makes {8}".to_string(), Some("2".to_string()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_lang_forces_english_for_saved_cjk_when_target_disables_cjk() {
|
||||||
|
use super::resolve_lang as f;
|
||||||
|
|
||||||
|
assert_eq!(f("zh-cn", "en-US", true), "en");
|
||||||
|
assert_eq!(f("zh-tw", "en-US", true), "en");
|
||||||
|
assert_eq!(f("ja", "en-US", true), "en");
|
||||||
|
assert_eq!(f("ko", "en-US", true), "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_lang_forces_english_for_cjk_locale_when_target_disables_cjk() {
|
||||||
|
use super::resolve_lang as f;
|
||||||
|
|
||||||
|
assert_eq!(f("", "zh_CN", true), "en");
|
||||||
|
assert_eq!(f("", "ja-JP", true), "en");
|
||||||
|
assert_eq!(f("", "ko_KR", true), "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_lang_preserves_cjk_when_target_allows_cjk() {
|
||||||
|
use super::resolve_lang as f;
|
||||||
|
|
||||||
|
assert_eq!(f("zh-cn", "en-US", false), "zh-cn");
|
||||||
|
assert_eq!(f("", "zh_TW", false), "zh-tw");
|
||||||
|
assert_eq!(f("", "ja-JP", false), "ja");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("remember-wayland-keyboard-choice-tip", "Non chiedere più per questo computer remoto"),
|
("remember-wayland-keyboard-choice-tip", "Non chiedere più per questo computer remoto"),
|
||||||
("Why this happens", "Perché accade questo"),
|
("Why this happens", "Perché accade questo"),
|
||||||
("Switch display", "Cambia schermo"),
|
("Switch display", "Cambia schermo"),
|
||||||
("Show monitor switch button on the main toolbar", "Mostra il pulsante di cambio monitor nella barra degli strumenti principale"),
|
("Show monitor switch button on the main toolbar", "Visualizza nella barra strumenti principale il pulsante per il cambio schermo"),
|
||||||
("Show on the minimized toolbar", "Mostra nella barra degli strumenti ridotta a icona"),
|
("Show on the minimized toolbar", "Visualizza nella barra strumenti ridotta a icona"),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Control Remote Desktop", "Controle um Computador Remoto"),
|
("Control Remote Desktop", "Controle um Computador Remoto"),
|
||||||
("Transfer file", "Transferir arquivos"),
|
("Transfer file", "Transferir arquivos"),
|
||||||
("Connect", "Conectar"),
|
("Connect", "Conectar"),
|
||||||
("Recent sessions", "Sessões Recentes"),
|
("Recent sessions", "Sessões recentes"),
|
||||||
("Address book", "Lista de Endereços"),
|
("Address book", "Lista de endereços"),
|
||||||
("Confirmation", "Confirmação"),
|
("Confirmation", "Confirmação"),
|
||||||
("TCP tunneling", "Tunelamento TCP"),
|
("TCP tunneling", "Tunelamento TCP"),
|
||||||
("Remove", "Remover"),
|
("Remove", "Remover"),
|
||||||
("Refresh random password", "Atualizar senha aleatória"),
|
("Refresh random password", "Gerar nova senha aleatória"),
|
||||||
("Set your own password", "Configure sua própria senha"),
|
("Set your own password", "Definir sua própria senha"),
|
||||||
("Enable keyboard/mouse", "Habilitar teclado/mouse"),
|
("Enable keyboard/mouse", "Habilitar teclado/mouse"),
|
||||||
("Enable clipboard", "Habilitar área de transferência"),
|
("Enable clipboard", "Habilitar área de transferência"),
|
||||||
("Enable file transfer", "Habilitar transferência de arquivos"),
|
("Enable file transfer", "Habilitar transferência de arquivos"),
|
||||||
("Enable TCP tunneling", "Habilitar tunelamento TCP"),
|
("Enable TCP tunneling", "Habilitar tunelamento TCP"),
|
||||||
("IP Whitelisting", "Lista de IPs Confiáveis"),
|
("IP Whitelisting", "Lista de IPs Permitidos"),
|
||||||
("ID/Relay Server", "Servidor ID/Relay"),
|
("ID/Relay Server", "Servidor ID/Relay"),
|
||||||
("Import server config", "Importar Configuração do Servidor"),
|
("Import server config", "Importar Configuração do Servidor"),
|
||||||
("Export Server Config", "Exportar Configuração do Servidor"),
|
("Export Server Config", "Exportar Configuração do Servidor"),
|
||||||
@@ -320,12 +320,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Exit Fullscreen", "Sair da Tela Cheia"),
|
("Exit Fullscreen", "Sair da Tela Cheia"),
|
||||||
("Fullscreen", "Tela Cheia"),
|
("Fullscreen", "Tela Cheia"),
|
||||||
("Mobile Actions", "Ações móveis"),
|
("Mobile Actions", "Ações móveis"),
|
||||||
("Select Monitor", "Selecionar monitor"),
|
("Select Monitor", "Selecionar tela"),
|
||||||
("Control Actions", "Controlar ações"),
|
("Control Actions", "Controlar ações"),
|
||||||
("Display Settings", "Configurações de exibição"),
|
("Display Settings", "Configurações de exibição"),
|
||||||
("Ratio", "Proporção"),
|
("Ratio", "Proporção"),
|
||||||
("Image Quality", "Qualidade de imagem"),
|
("Image Quality", "Qualidade de imagem"),
|
||||||
("Scroll Style", "Estilo de Rolagem"),
|
("Scroll Style", "Estilo de rolagem"),
|
||||||
("Show Toolbar", "Mostrar barra de ferramentas"),
|
("Show Toolbar", "Mostrar barra de ferramentas"),
|
||||||
("Hide Toolbar", "Ocultar barra de ferramentas"),
|
("Hide Toolbar", "Ocultar barra de ferramentas"),
|
||||||
("Direct Connection", "Conexão Direta"),
|
("Direct Connection", "Conexão Direta"),
|
||||||
@@ -353,7 +353,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Disconnect all devices?", "Desconectar todos os dispositivos?"),
|
("Disconnect all devices?", "Desconectar todos os dispositivos?"),
|
||||||
("Clear", "Limpar"),
|
("Clear", "Limpar"),
|
||||||
("Audio Input Device", "Dispositivo de entrada de áudio"),
|
("Audio Input Device", "Dispositivo de entrada de áudio"),
|
||||||
("Use IP Whitelisting", "Utilizar lista de IPs confiáveis"),
|
("Use IP Whitelisting", "Utilizar lista de IPs permitidos"),
|
||||||
("Network", "Rede"),
|
("Network", "Rede"),
|
||||||
("Pin Toolbar", "Fixar barra de ferramentas"),
|
("Pin Toolbar", "Fixar barra de ferramentas"),
|
||||||
("Unpin Toolbar", "Desafixar barra de ferramentas"),
|
("Unpin Toolbar", "Desafixar barra de ferramentas"),
|
||||||
@@ -463,7 +463,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Empty Password", "Senha Vazia"),
|
("Empty Password", "Senha Vazia"),
|
||||||
("Me", "Eu"),
|
("Me", "Eu"),
|
||||||
("identical_file_tip", "Este arquivo é idêntico ao do parceiro."),
|
("identical_file_tip", "Este arquivo é idêntico ao do parceiro."),
|
||||||
("show_monitors_tip", "Mostrar monitores na barra de ferramentas"),
|
("show_monitors_tip", "Mostrar telas na barra de ferramentas"),
|
||||||
("View Mode", "Modo de visualização"),
|
("View Mode", "Modo de visualização"),
|
||||||
("login_linux_tip", "Você precisa fazer login na conta Linux remota para habilitar uma sessão de desktop X"),
|
("login_linux_tip", "Você precisa fazer login na conta Linux remota para habilitar uma sessão de desktop X"),
|
||||||
("verify_rustdesk_password_tip", "Verifique a senha do RustDesk"),
|
("verify_rustdesk_password_tip", "Verifique a senha do RustDesk"),
|
||||||
@@ -674,7 +674,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("dont-show-again-tip", "Não mostrar novamente"),
|
("dont-show-again-tip", "Não mostrar novamente"),
|
||||||
("Take screenshot", "Capturar tela"),
|
("Take screenshot", "Capturar tela"),
|
||||||
("Taking screenshot", "Capturando tela"),
|
("Taking screenshot", "Capturando tela"),
|
||||||
("screenshot-merged-screen-not-supported-tip", "Mesclar a captura de tela de múltiplos monitores não é suportada no momento. Por favor, alterne para um único monitor e tente novamente."),
|
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
|
||||||
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
||||||
("Save as", "Salvar como"),
|
("Save as", "Salvar como"),
|
||||||
("Copy to clipboard", "Copiar para área de transferência"),
|
("Copy to clipboard", "Copiar para área de transferência"),
|
||||||
@@ -694,7 +694,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Enable UDP hole punching", "Habilitar UDP hole punching"),
|
("Enable UDP hole punching", "Habilitar UDP hole punching"),
|
||||||
("View camera", "Visualizar câmera"),
|
("View camera", "Visualizar câmera"),
|
||||||
("Enable camera", "Habilitar câmera"),
|
("Enable camera", "Habilitar câmera"),
|
||||||
("No cameras", "Nenhuma câmeras"),
|
("No cameras", "Nenhuma câmera"),
|
||||||
("view_camera_unsupported_tip", "O dispositivo remoto não suporta visualização da câmera."),
|
("view_camera_unsupported_tip", "O dispositivo remoto não suporta visualização da câmera."),
|
||||||
("Terminal", "Terminal"),
|
("Terminal", "Terminal"),
|
||||||
("Enable terminal", "Habilitar terminal"),
|
("Enable terminal", "Habilitar terminal"),
|
||||||
@@ -759,7 +759,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("remember-wayland-keyboard-choice-tip", "Não perguntar novamente para este computador remoto"),
|
("remember-wayland-keyboard-choice-tip", "Não perguntar novamente para este computador remoto"),
|
||||||
("Why this happens", "Por que isso acontece"),
|
("Why this happens", "Por que isso acontece"),
|
||||||
("Switch display", "Trocar de tela"),
|
("Switch display", "Trocar de tela"),
|
||||||
("Show monitor switch button on the main toolbar", "Mostrar o botão de troca de monitor na barra de ferramentas principal"),
|
("Show monitor switch button on the main toolbar", "Mostrar botão de troca de tela na barra de ferramentas"),
|
||||||
("Show on the minimized toolbar", "Mostrar na barra de ferramentas minimizada"),
|
("Show on the minimized toolbar", "Mostrar na barra de ferramentas minimizada"),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ fn check_desktop_manager() {
|
|||||||
pub fn start_xdesktop() {
|
pub fn start_xdesktop() {
|
||||||
debug_assert!(crate::is_server());
|
debug_assert!(crate::is_server());
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
|
DesktopManager::recover_orphaned_session();
|
||||||
*DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new());
|
*DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new());
|
||||||
|
|
||||||
let interval = time::Duration::from_millis(super::SERVICE_INTERVAL);
|
let interval = time::Duration::from_millis(super::SERVICE_INTERVAL);
|
||||||
@@ -462,9 +463,10 @@ impl DesktopManager {
|
|||||||
let (child_xorg, child_wm) = Self::start_x11(uid, gid, username, display_num, &envs)?;
|
let (child_xorg, child_wm) = Self::start_x11(uid, gid, username, display_num, &envs)?;
|
||||||
is_child_running.store(true, Ordering::SeqCst);
|
is_child_running.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
// capture the logind session scope (from a live child) for teardown, see
|
// capture the logind session scope (from a live child) for teardown and crash
|
||||||
// reap_session_scope.
|
// recovery, see reap_session_scope and recover_orphaned_session.
|
||||||
let scope_dir = Self::session_scope_dir(child_xorg.id());
|
let scope_dir = Self::session_scope_dir(child_xorg.id());
|
||||||
|
Self::save_orphaned_marker(&scope_dir, display_num);
|
||||||
|
|
||||||
log::info!("Start xorg and wm done, notify and wait xtop x11");
|
log::info!("Start xorg and wm done, notify and wait xtop x11");
|
||||||
allow_err!(tx_res.send("".to_owned()));
|
allow_err!(tx_res.send("".to_owned()));
|
||||||
@@ -879,6 +881,66 @@ impl DesktopManager {
|
|||||||
std::io::Error::last_os_error().raw_os_error() == Some(hbb_common::libc::EPERM)
|
std::io::Error::last_os_error().raw_os_error() == Some(hbb_common::libc::EPERM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ORPHANED_SESSION_KEY: &'static str = "headless-orphaned-session";
|
||||||
|
|
||||||
|
fn save_orphaned_marker(scope_dir: &str, display_num: u32) {
|
||||||
|
// tag the marker with this boot's id: a logind session id is only unique within a
|
||||||
|
// boot (the counter lives in /run and resets), so recovery must not reap a recorded
|
||||||
|
// scope path after a reboot, when it may name a different live session.
|
||||||
|
let boot_id = Self::current_boot_id().unwrap_or_default();
|
||||||
|
hbb_common::config::LocalConfig::set_option(
|
||||||
|
Self::ORPHANED_SESSION_KEY.to_owned(),
|
||||||
|
format!("{};{};{}", scope_dir, display_num, boot_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_boot_id() -> Option<String> {
|
||||||
|
std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_orphaned_marker() {
|
||||||
|
hbb_common::config::LocalConfig::set_option(
|
||||||
|
Self::ORPHANED_SESSION_KEY.to_owned(),
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_orphaned_marker(marker: &str) -> Option<(&str, u32, &str)> {
|
||||||
|
let (rest, boot_id) = marker.rsplit_once(';')?;
|
||||||
|
let (scope_dir, display) = rest.rsplit_once(';')?;
|
||||||
|
Some((scope_dir, display.trim().parse::<u32>().ok()?, boot_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a run that dies before wait_stop_x11 (service or --server crash) leaks the headless
|
||||||
|
// session scope + X lock files, the same as a missed teardown (rustdesk/rustdesk#15183).
|
||||||
|
// reap exactly what the dead run recorded - never a scan, so unrelated sessions are safe.
|
||||||
|
fn recover_orphaned_session() {
|
||||||
|
let marker = hbb_common::config::LocalConfig::get_option(Self::ORPHANED_SESSION_KEY);
|
||||||
|
if marker.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((scope_dir, display_num, boot_id)) = Self::parse_orphaned_marker(&marker) {
|
||||||
|
// only reap the recorded scope when the marker is from this same boot: a leaked
|
||||||
|
// cgroup cannot outlive a reboot, so cross-boot there is nothing legitimate to
|
||||||
|
// reap, and the recorded "session-N.scope" may by then name a different live
|
||||||
|
// session. the X lock cleanup is pid-guarded, so run it either way.
|
||||||
|
let same_boot = Self::current_boot_id().map_or(false, |b| b == boot_id);
|
||||||
|
log::info!(
|
||||||
|
"Recovering leaked headless session from a previous run: scope {}, display {} (same boot: {})",
|
||||||
|
scope_dir,
|
||||||
|
display_num,
|
||||||
|
same_boot
|
||||||
|
);
|
||||||
|
if same_boot {
|
||||||
|
Self::reap_session_scope(scope_dir);
|
||||||
|
}
|
||||||
|
Self::cleanup_x_display_files(display_num);
|
||||||
|
}
|
||||||
|
Self::clear_orphaned_marker();
|
||||||
|
}
|
||||||
|
|
||||||
fn try_wait_stop_x11(
|
fn try_wait_stop_x11(
|
||||||
child_xorg: &mut Child,
|
child_xorg: &mut Child,
|
||||||
child_wm: &mut Child,
|
child_wm: &mut Child,
|
||||||
@@ -898,6 +960,7 @@ impl DesktopManager {
|
|||||||
Self::wait_x11_children_exit(child_xorg, child_wm);
|
Self::wait_x11_children_exit(child_xorg, child_wm);
|
||||||
Self::reap_session_scope(scope_dir);
|
Self::reap_session_scope(scope_dir);
|
||||||
Self::cleanup_x_display_files(display_num);
|
Self::cleanup_x_display_files(display_num);
|
||||||
|
Self::clear_orphaned_marker();
|
||||||
desktop_manager
|
desktop_manager
|
||||||
.is_child_running
|
.is_child_running
|
||||||
.store(false, Ordering::SeqCst);
|
.store(false, Ordering::SeqCst);
|
||||||
@@ -1082,4 +1145,27 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(pids, vec![100, 101, 200, 300]);
|
assert_eq!(pids, vec![100, 101, 200, 300]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_orphaned_session_marker() {
|
||||||
|
assert_eq!(
|
||||||
|
DesktopManager::parse_orphaned_marker(
|
||||||
|
"/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope;7;abc-123"
|
||||||
|
),
|
||||||
|
Some((
|
||||||
|
"/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope",
|
||||||
|
7,
|
||||||
|
"abc-123"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
// an empty scope still carries the display so its stale X lock can be cleaned
|
||||||
|
assert_eq!(DesktopManager::parse_orphaned_marker(";5;abc-123"), Some(("", 5, "abc-123")));
|
||||||
|
// an empty boot id never matches the live one, so the scope reap is skipped
|
||||||
|
assert_eq!(DesktopManager::parse_orphaned_marker("/scope;5;"), Some(("/scope", 5, "")));
|
||||||
|
assert_eq!(DesktopManager::parse_orphaned_marker(""), None);
|
||||||
|
assert_eq!(DesktopManager::parse_orphaned_marker("garbage"), None);
|
||||||
|
// the pre-boot-id two-field format no longer parses, recovery just skips it
|
||||||
|
assert_eq!(DesktopManager::parse_orphaned_marker("/scope;7"), None);
|
||||||
|
assert_eq!(DesktopManager::parse_orphaned_marker("/scope;notnum;abc"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -902,8 +902,10 @@ pub fn get_async_job_status() -> String {
|
|||||||
#[inline]
|
#[inline]
|
||||||
pub fn get_langs() -> String {
|
pub fn get_langs() -> String {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
let hide_cjk = crate::lang::cjk_ui_unavailable();
|
||||||
let mut x: Vec<(&str, String)> = crate::lang::LANGS
|
let mut x: Vec<(&str, String)> = crate::lang::LANGS
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|a| !hide_cjk || !crate::lang::is_cjk_lang(a.0))
|
||||||
.map(|a| (a.0, format!("{} ({})", a.1, a.0)))
|
.map(|a| (a.0, format!("{} ({})", a.1, a.0)))
|
||||||
.collect();
|
.collect();
|
||||||
x.sort_by(|a, b| a.0.cmp(b.0));
|
x.sort_by(|a, b| a.0.cmp(b.0));
|
||||||
|
|||||||
Reference in New Issue
Block a user