diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index eb22d4cc4..903eb0e11 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -1398,6 +1398,17 @@ Future saveWindowPosition(WindowType type, {int? windowId}) async { isMaximized = await wc.isMaximized(); break; } + if (Platform.isWindows) { + const kMinOffset = -10000; + const kMaxOffset = 10000; + if (position.dx < kMinOffset || + position.dy < kMinOffset || + position.dx > kMaxOffset || + position.dy > kMaxOffset) { + debugPrint("Invalid position: $position, ignore saving position"); + return; + } + } final pos = LastWindowPosition( sz.width, sz.height, position.dx, position.dy, isMaximized); diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index af6e00f4a..2e553e724 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -4,7 +4,6 @@ import 'dart:ui' as ui; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/consts.dart'; @@ -172,7 +171,7 @@ class _ConnectionTabPageState extends State { connectionType.secure.value == ConnectionType.strSecure; bool direct = connectionType.direct.value == ConnectionType.strDirect; - var msgConn; + String msgConn; if (secure && direct) { msgConn = translate("Direct and encrypted connection"); } else if (secure && !direct) { diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 8c2f6a0b0..dcf78b27b 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:ui' as ui; import 'dart:async'; import 'dart:io'; @@ -360,6 +359,9 @@ class _RemoteToolbarState extends State { triggerAutoHide() => _debouncerHide.value = _debouncerHide.value + 1; + void _minimize() async => + await WindowController.fromWindowId(windowId).minimize(); + @override initState() { super.initState(); @@ -429,6 +431,8 @@ class _RemoteToolbarState extends State { dragging: _dragging, fractionX: _fractionX, show: show, + setFullscreen: _setFullscreen, + setMinimize: _minimize, ), ), ), @@ -440,8 +444,6 @@ class _RemoteToolbarState extends State { final List toolbarItems = []; if (!isWebDesktop) { toolbarItems.add(_PinMenu(state: widget.state)); - toolbarItems.add( - _FullscreenMenu(state: widget.state, setFullscreen: _setFullscreen)); toolbarItems.add(_MobileActionMenu(ffi: widget.ffi)); } @@ -549,27 +551,6 @@ class _PinMenu extends StatelessWidget { } } -class _FullscreenMenu extends StatelessWidget { - final ToolbarState state; - final Function(bool) setFullscreen; - bool get isFullscreen => stateGlobal.fullscreen; - const _FullscreenMenu( - {Key? key, required this.state, required this.setFullscreen}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return _IconMenuButton( - assetName: - isFullscreen ? "assets/fullscreen_exit.svg" : "assets/fullscreen.svg", - tooltip: isFullscreen ? 'Exit Fullscreen' : 'Fullscreen', - onPressed: () => setFullscreen(!isFullscreen), - color: _ToolbarTheme.blueColor, - hoverColor: _ToolbarTheme.hoverBlueColor, - ); - } -} - class _MobileActionMenu extends StatelessWidget { final FFI ffi; const _MobileActionMenu({Key? key, required this.ffi}) : super(key: key); @@ -614,7 +595,7 @@ class _MonitorMenu extends StatelessWidget { children: [ SvgPicture.asset( "assets/screen.svg", - color: Colors.white, + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Obx(() { RxInt display = CurrentDisplayState.find(id); @@ -650,7 +631,7 @@ class _MonitorMenu extends StatelessWidget { children: [ SvgPicture.asset( "assets/screen.svg", - color: Colors.white, + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Text( (i + 1).toString(), @@ -721,7 +702,7 @@ class ScreenAdjustor { bool get isFullscreen => stateGlobal.fullscreen; int get windowId => stateGlobal.windowId; - adjustWindow() { + adjustWindow(BuildContext context) { return futureBuilder( future: isWindowCanBeAdjusted(), hasData: (data) { @@ -731,7 +712,7 @@ class ScreenAdjustor { children: [ MenuButton( child: Text(translate('Adjust Window')), - onPressed: doAdjustWindow, + onPressed: () => doAdjustWindow(context), ffi: ffi), Divider(), ], @@ -739,20 +720,19 @@ class ScreenAdjustor { }); } - doAdjustWindow() async { + doAdjustWindow(BuildContext context) async { await updateScreen(); if (_screen != null) { cbExitFullscreen(); double scale = _screen!.scaleFactor; final wndRect = await WindowController.fromWindowId(windowId).getFrame(); - final mediaSize = MediaQueryData.fromWindow(ui.window).size; + final mediaSize = MediaQueryData.fromView(View.of(context)).size; // On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. // https://stackoverflow.com/a/7561083 double magicWidth = wndRect.right - wndRect.left - mediaSize.width * scale; double magicHeight = wndRect.bottom - wndRect.top - mediaSize.height * scale; - final canvasModel = ffi.canvasModel; final width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + @@ -895,7 +875,7 @@ class _DisplayMenuState extends State<_DisplayMenu> { color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildren: [ - _screenAdjustor.adjustWindow(), + _screenAdjustor.adjustWindow(context), viewStyle(), scrollStyle(), imageQuality(), @@ -1082,9 +1062,9 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { return _SubmenuButton( ffi: widget.ffi, menuChildren: [ - _OriginalResolutionMenuButton(showOriginalBtn), - _FitLocalResolutionMenuButton(showFitLocalBtn), - _customResolutionMenuButton(isVirtualDisplay), + _OriginalResolutionMenuButton(context, showOriginalBtn), + _FitLocalResolutionMenuButton(context, showFitLocalBtn), + _customResolutionMenuButton(context, isVirtualDisplay), _menuDivider(showOriginalBtn, showFitLocalBtn, isVirtualDisplay), ] + _supportedResolutionMenuButtons(), @@ -1125,7 +1105,7 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { } } - _onChanged(String? value) async { + _onChanged(BuildContext context, String? value) async { stateGlobal.setLastResolutionGroupValue( widget.id, pi.currentDisplay, value); if (value == null) return; @@ -1145,12 +1125,12 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { if (w != null && h != null) { if (w != display.width || h != display.height) { - await _changeResolution(w, h); + await _changeResolution(context, w, h); } } } - _changeResolution(int w, int h) async { + _changeResolution(BuildContext context, int w, int h) async { await bind.sessionChangeResolution( sessionId: ffi.sessionId, display: pi.currentDisplay, @@ -1161,18 +1141,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { final display = ffiModel.display; if (w == display.width && h == display.height) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - widget.screenAdjustor.doAdjustWindow(); + widget.screenAdjustor.doAdjustWindow(context); } } }); } - Widget _OriginalResolutionMenuButton(bool showOriginalBtn) { + Widget _OriginalResolutionMenuButton( + BuildContext context, bool showOriginalBtn) { return Offstage( offstage: !showOriginalBtn, child: MenuButton( - onPressed: () => - _changeResolution(display.originalWidth, display.originalHeight), + onPressed: () => _changeResolution( + context, display.originalWidth, display.originalHeight), ffi: widget.ffi, child: Text( '${translate('resolution_original_tip')} ${display.originalWidth}x${display.originalHeight}'), @@ -1180,14 +1161,15 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { ); } - Widget _FitLocalResolutionMenuButton(bool showFitLocalBtn) { + Widget _FitLocalResolutionMenuButton( + BuildContext context, bool showFitLocalBtn) { return Offstage( offstage: !showFitLocalBtn, child: MenuButton( onPressed: () { final resolution = _getBestFitResolution(); if (resolution != null) { - _changeResolution(resolution.width, resolution.height); + _changeResolution(context, resolution.width, resolution.height); } }, ffi: widget.ffi, @@ -1197,13 +1179,13 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { ); } - Widget _customResolutionMenuButton(isVirtualDisplay) { + Widget _customResolutionMenuButton(BuildContext context, isVirtualDisplay) { return Offstage( offstage: !isVirtualDisplay, child: RdoMenuButton( value: _kCustomResolutionValue, groupValue: _groupValue, - onChanged: _onChanged, + onChanged: (String? value) => _onChanged(context, value), ffi: widget.ffi, child: Row( children: [ @@ -1244,7 +1226,7 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { .map((e) => RdoMenuButton( value: '${e.width}x${e.height}', groupValue: _groupValue, - onChanged: _onChanged, + onChanged: (String? value) => _onChanged(context, value), ffi: widget.ffi, child: Text('${e.width}x${e.height}'))) .toList(); @@ -1597,11 +1579,11 @@ class _IconMenuButtonState extends State<_IconMenuButton> { final icon = widget.icon ?? SvgPicture.asset( widget.assetName!, - color: Colors.white, + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, ); - final button = SizedBox( + var button = SizedBox( width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, child: MenuItemButton( @@ -1625,6 +1607,12 @@ class _IconMenuButtonState extends State<_IconMenuButton> { ).marginSymmetric( horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin, vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin); + if (widget.tooltip != null) { + button = Tooltip( + message: widget.tooltip!, + child: button, + ); + } if (widget.topLevel) { return MenuBar(children: [button]); } else { @@ -1668,7 +1656,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> { final icon = widget.icon ?? SvgPicture.asset( widget.svg!, - color: Colors.white, + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, ); @@ -1817,12 +1805,18 @@ class _DraggableShowHide extends StatefulWidget { final RxDouble fractionX; final RxBool dragging; final RxBool show; + + final Function(bool) setFullscreen; + final Function() setMinimize; + const _DraggableShowHide({ Key? key, required this.sessionId, required this.fractionX, required this.dragging, required this.show, + required this.setFullscreen, + required this.setMinimize, }) : super(key: key); @override @@ -1876,7 +1870,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { widget.dragging.value = true; }), onDragEnd: (details) { - final mediaSize = MediaQueryData.fromWindow(ui.window).size; + final mediaSize = MediaQueryData.fromView(View.of(context)).size; widget.fractionX.value += (details.offset.dx - position.dx) / (mediaSize.width - size.width); if (widget.fractionX.value < left) { @@ -1901,17 +1895,49 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { minimumSize: MaterialStateProperty.all(const Size(0, 0)), padding: MaterialStateProperty.all(EdgeInsets.zero), ); + final isFullscreen = stateGlobal.fullscreen; + const double iconSize = 20; final child = Row( mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), + TextButton( + onPressed: () { + widget.setFullscreen(!isFullscreen); + setState(() {}); + }, + child: Tooltip( + message: translate(isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'), + child: Icon( + isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + size: iconSize, + ), + ), + ), + Offstage( + offstage: !isFullscreen, + child: TextButton( + onPressed: () => widget.setMinimize(), + child: Tooltip( + message: translate('Minimize'), + child: Icon( + Icons.remove, + size: iconSize, + ), + ), + ), + ), TextButton( onPressed: () => setState(() { widget.show.value = !widget.show.value; }), - child: Obx((() => Icon( - widget.show.isTrue ? Icons.expand_less : Icons.expand_more, - size: 20, + child: Obx((() => Tooltip( + message: translate( + widget.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), + child: Icon( + widget.show.isTrue ? Icons.expand_less : Icons.expand_more, + size: iconSize, + ), ))), ), ], @@ -1993,7 +2019,8 @@ class _MultiMonitorMenu extends StatelessWidget { children: [ SvgPicture.asset( "assets/screen.svg", - color: Colors.white, + colorFilter: + ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Obx( () => Text( diff --git a/flutter/lib/models/state_model.dart b/flutter/lib/models/state_model.dart index f7b4f8cc2..94f9cc234 100644 --- a/flutter/lib/models/state_model.dart +++ b/flutter/lib/models/state_model.dart @@ -65,7 +65,7 @@ class StateGlobal { ? kMaximizeEdgeSize : kWindowEdgeSize; print( - "fullscreen: ${fullscreen}, resizeEdgeSize: ${_resizeEdgeSize.value}"); + "fullscreen: $fullscreen, resizeEdgeSize: ${_resizeEdgeSize.value}"); _windowBorderWidth.value = fullscreen ? 0 : kWindowBorderWidth; WindowController.fromWindowId(windowId) .setFullscreen(_fullscreen) diff --git a/libs/clipboard/src/lib.rs b/libs/clipboard/src/lib.rs index 84179621d..7a5941029 100644 --- a/libs/clipboard/src/lib.rs +++ b/libs/clipboard/src/lib.rs @@ -181,32 +181,37 @@ pub fn server_clip_file( ClipboardFile::MonitorReady => { log::debug!("server_monitor_ready called"); ret = server_monitor_ready(context, conn_id); - log::debug!("server_monitor_ready called, return {}", ret); + log::debug!("server_monitor_ready called, conn_id {}, return {}", conn_id, ret); } ClipboardFile::FormatList { format_list } => { - log::debug!("server_format_list called"); + log::debug!("server_format_list called, conn_id {}, format_list: {:?}", conn_id, &format_list); ret = server_format_list(context, conn_id, format_list); - log::debug!("server_format_list called, return {}", ret); + log::debug!("server_format_list called, conn_id {}, return {}", conn_id, ret); } ClipboardFile::FormatListResponse { msg_flags } => { - log::debug!("format_list_response called"); + log::debug!("server_format_list_response called"); ret = server_format_list_response(context, conn_id, msg_flags); - log::debug!("server_format_list_response called, return {}", ret); + log::debug!("server_format_list_response called, conn_id {}, msg_flags {}, return {}", conn_id, msg_flags, ret); } ClipboardFile::FormatDataRequest { requested_format_id, } => { - log::debug!("format_data_request called"); + log::debug!("server_format_data_request called"); ret = server_format_data_request(context, conn_id, requested_format_id); - log::debug!("server_format_data_request called, return {}", ret); + log::debug!("server_format_data_request called, conn_id {}, requested_format_id {}, return {}", conn_id, requested_format_id, ret); } ClipboardFile::FormatDataResponse { msg_flags, format_data, } => { - log::debug!("format_data_response called"); + log::debug!("server_format_data_response called"); ret = server_format_data_response(context, conn_id, msg_flags, format_data); - log::debug!("server_format_data_response called, return {}", ret); + log::debug!( + "server_format_data_response called, conn_id {}, msg_flags: {}, return {}", + conn_id, + msg_flags, + ret + ); } ClipboardFile::FileContentsRequest { stream_id, @@ -218,7 +223,7 @@ pub fn server_clip_file( have_clip_data_id, clip_data_id, } => { - log::debug!("file_contents_request called"); + log::debug!("server_file_contents_request called"); ret = server_file_contents_request( context, conn_id, @@ -231,14 +236,24 @@ pub fn server_clip_file( have_clip_data_id, clip_data_id, ); - log::debug!("server_file_contents_request called, return {}", ret); + log::debug!("server_file_contents_request called, conn_id {}, stream_id: {}, list_index {}, dw_flags {}, n_position_low {}, n_position_high {}, cb_requested {}, have_clip_data_id {}, clip_data_id {}, return {}", conn_id, + stream_id, + list_index, + dw_flags, + n_position_low, + n_position_high, + cb_requested, + have_clip_data_id, + clip_data_id, + ret + ); } ClipboardFile::FileContentsResponse { msg_flags, stream_id, requested_data, } => { - log::debug!("file_contents_response called"); + log::debug!("server_file_contents_response called"); ret = server_file_contents_response( context, conn_id, @@ -246,7 +261,12 @@ pub fn server_clip_file( stream_id, requested_data, ); - log::debug!("server_file_contents_response called, return {}", ret); + log::debug!("server_file_contents_response called, conn_id {}, msg_flags {}, stream_id {}, return {}", + conn_id, + msg_flags, + stream_id, + ret + ); } } ret @@ -515,8 +535,6 @@ extern "C" fn client_format_list( _context: *mut CliprdrClientContext, clip_format_list: *const CLIPRDR_FORMAT_LIST, ) -> UINT { - log::debug!("client_format_list called"); - let conn_id; let mut format_list: Vec<(i32, String)> = Vec::new(); unsafe { @@ -541,6 +559,7 @@ extern "C" fn client_format_list( } conn_id = (*clip_format_list).connID as i32; } + log::debug!("client_format_list called, client id: {}, format_list: {:?}", conn_id, &format_list); let data = ClipboardFile::FormatList { format_list }; // no need to handle result here if conn_id == 0 { @@ -560,14 +579,13 @@ extern "C" fn client_format_list_response( _context: *mut CliprdrClientContext, format_list_response: *const CLIPRDR_FORMAT_LIST_RESPONSE, ) -> UINT { - log::debug!("client_format_list_response called"); - let conn_id; let msg_flags; unsafe { conn_id = (*format_list_response).connID as i32; msg_flags = (*format_list_response).msgFlags as i32; } + log::debug!("client_format_list_response called, client id: {}, msg_flags: {}", conn_id, msg_flags); let data = ClipboardFile::FormatListResponse { msg_flags }; send_data(conn_id, data); @@ -578,8 +596,6 @@ extern "C" fn client_format_data_request( _context: *mut CliprdrClientContext, format_data_request: *const CLIPRDR_FORMAT_DATA_REQUEST, ) -> UINT { - log::debug!("client_format_data_request called"); - let conn_id; let requested_format_id; unsafe { @@ -589,6 +605,7 @@ extern "C" fn client_format_data_request( let data = ClipboardFile::FormatDataRequest { requested_format_id, }; + log::debug!("client_format_data_request called, conn_id: {}, requested_format_id: {}", conn_id, requested_format_id); // no need to handle result here send_data(conn_id, data); @@ -599,8 +616,6 @@ extern "C" fn client_format_data_response( _context: *mut CliprdrClientContext, format_data_response: *const CLIPRDR_FORMAT_DATA_RESPONSE, ) -> UINT { - log::debug!("cconn_idlient_format_data_response called"); - let conn_id; let msg_flags; let format_data; @@ -617,6 +632,7 @@ extern "C" fn client_format_data_response( .to_vec(); } } + log::debug!("client_format_data_response called, client id: {}, msg_flags: {}", conn_id, msg_flags); let data = ClipboardFile::FormatDataResponse { msg_flags, format_data, @@ -630,8 +646,6 @@ extern "C" fn client_file_contents_request( _context: *mut CliprdrClientContext, file_contents_request: *const CLIPRDR_FILE_CONTENTS_REQUEST, ) -> UINT { - log::debug!("client_file_contents_request called"); - // TODO: support huge file? // if (!cliprdr->hasHugeFileSupport) // { @@ -662,7 +676,6 @@ extern "C" fn client_file_contents_request( have_clip_data_id = (*file_contents_request).haveClipDataId == TRUE; clip_data_id = (*file_contents_request).clipDataId as i32; } - let data = ClipboardFile::FileContentsRequest { stream_id, list_index, @@ -673,6 +686,7 @@ extern "C" fn client_file_contents_request( have_clip_data_id, clip_data_id, }; + log::debug!("client_file_contents_request called, data: {:?}", &data); send_data(conn_id, data); 0 @@ -682,8 +696,6 @@ extern "C" fn client_file_contents_response( _context: *mut CliprdrClientContext, file_contents_response: *const CLIPRDR_FILE_CONTENTS_RESPONSE, ) -> UINT { - log::debug!("client_file_contents_response called"); - let conn_id; let msg_flags; let stream_id; @@ -707,6 +719,7 @@ extern "C" fn client_file_contents_response( stream_id, requested_data, }; + log::debug!("client_file_contents_response called, conn_id: {}, msg_flags: {}, stream_id: {}", conn_id, msg_flags, stream_id); send_data(conn_id, data); 0 diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index 801fa71f3..c8f2038a1 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -1367,6 +1367,11 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) if (!clipboard) return ERROR_INTERNAL_ERROR; + if (!IsClipboardFormatAvailable(CF_HDROP)) + { + return ERROR_SUCCESS; + } + ZeroMemory(&formatList, sizeof(CLIPRDR_FORMAT_LIST)); /* Ignore if other app is holding clipboard */ @@ -1392,21 +1397,11 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) } index = 0; - - if (IsClipboardFormatAvailable(CF_HDROP)) - { - UINT fsid = RegisterClipboardFormat(CFSTR_FILEDESCRIPTORW); - UINT fcid = RegisterClipboardFormat(CFSTR_FILECONTENTS); - - formats[index++].formatId = fsid; - formats[index++].formatId = fcid; - } - else - { - while (formatId = EnumClipboardFormats(formatId) && index < numFormats) - formats[index++].formatId = formatId; - } - + // IsClipboardFormatAvailable(CF_HDROP) is checked above + UINT fsid = RegisterClipboardFormat(CFSTR_FILEDESCRIPTORW); + UINT fcid = RegisterClipboardFormat(CFSTR_FILECONTENTS); + formats[index++].formatId = fsid; + formats[index++].formatId = fcid; numFormats = index; if (!CloseClipboard()) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 92bd7fd1f..c470f2270 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -518,11 +518,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Exit", "Verlaten"), ("Open", "Open"), ("logout_tip", "Weet je zeker dat je je wilt afmelden?"), - ("Service", ""), - ("Start", ""), - ("Stop", ""), - ("exceed_max_devices", ""), - ("Sync with recent sessions", ""), - ("Sort tags", ""), + ("Service", "Service"), + ("Start", "Start"), + ("Stop", "Stop"), + ("exceed_max_devices", "Het maximum aantal gecontroleerde apparaten is bereikt."), + ("Sync with recent sessions", "Recente sessies synchroniseren"), + ("Sort tags", "Labels sorteren"), ].iter().cloned().collect(); }