Compare commits
9 Commits
copilot/fi
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ee2585f4 | ||
|
|
5cae201c51 | ||
|
|
a46e0ff19d | ||
|
|
6306f83316 | ||
|
|
96075fdf49 | ||
|
|
8c6dcf53a6 | ||
|
|
e1b1a927b8 | ||
|
|
1e6bfa7bb1 | ||
|
|
79ef4c4501 |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 511 B After Width: | Height: | Size: 969 B |
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 755 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.4 KiB |
@@ -1124,18 +1124,23 @@ class CustomAlertDialog extends StatelessWidget {
|
||||
|
||||
Widget createDialogContent(String text) {
|
||||
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
||||
bool hasLink = linkRegExp.hasMatch(text);
|
||||
|
||||
// Early return: no link, use default theme color
|
||||
if (!hasLink) {
|
||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||
}
|
||||
|
||||
final List<TextSpan> spans = [];
|
||||
int start = 0;
|
||||
bool hasLink = false;
|
||||
|
||||
linkRegExp.allMatches(text).forEach((match) {
|
||||
hasLink = true;
|
||||
if (match.start > start) {
|
||||
spans.add(TextSpan(text: text.substring(start, match.start)));
|
||||
}
|
||||
spans.add(TextSpan(
|
||||
text: match.group(0) ?? '',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
@@ -1153,13 +1158,9 @@ Widget createDialogContent(String text) {
|
||||
spans.add(TextSpan(text: text.substring(start)));
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||
}
|
||||
|
||||
return SelectableText.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(color: Colors.black, fontSize: 15),
|
||||
style: const TextStyle(fontSize: 15),
|
||||
children: spans,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -107,6 +107,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
// For mouse mode, we need to block the events when the cursor is in a blocked area.
|
||||
// So we need to cache the last tap down position.
|
||||
Offset? _lastTapDownPositionForMouseMode;
|
||||
// Cache global position for onTap (which lacks position info).
|
||||
Offset? _lastTapDownGlobalPosition;
|
||||
|
||||
FFI get ffi => widget.ffi;
|
||||
FfiModel get ffiModel => widget.ffiModel;
|
||||
@@ -136,6 +138,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
|
||||
onTapDown(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
_lastTapDownGlobalPosition = d.globalPosition;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
@@ -154,6 +157,10 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
final isMoved =
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
@@ -171,6 +178,11 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
final lastPos = _lastTapDownGlobalPosition;
|
||||
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
@@ -41,6 +42,9 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
|
||||
// For web only.
|
||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||
@@ -147,7 +151,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
return Scaffold(
|
||||
final scaffold = Scaffold(
|
||||
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
@@ -192,9 +196,108 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
),
|
||||
),
|
||||
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
|
||||
// iOS-style circular close button in top-right corner
|
||||
if (isIOS) _buildCloseButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Add iOS edge swipe gesture to exit (similar to Android back button)
|
||||
if (isIOS) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenWidth = constraints.maxWidth;
|
||||
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
|
||||
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
|
||||
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
|
||||
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
|
||||
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
|
||||
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
|
||||
() => HorizontalDragGestureRecognizer(
|
||||
debugOwner: this,
|
||||
// Only respond to touch input, exclude mouse/trackpad
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
(HorizontalDragGestureRecognizer instance) {
|
||||
instance
|
||||
// Capture initial touch-down position (before touch slop)
|
||||
..onDown = (details) {
|
||||
_swipeStartX = details.localPosition.dx;
|
||||
_swipeCurrentX = details.localPosition.dx;
|
||||
}
|
||||
..onUpdate = (details) {
|
||||
_swipeCurrentX = details.localPosition.dx;
|
||||
}
|
||||
..onEnd = (details) {
|
||||
// Check if swipe started from left edge and moved right
|
||||
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
|
||||
clientClose(sessionId, _ffi);
|
||||
}
|
||||
_swipeStartX = 0;
|
||||
_swipeCurrentX = 0;
|
||||
}
|
||||
..onCancel = () {
|
||||
_swipeStartX = 0;
|
||||
_swipeCurrentX = 0;
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: scaffold,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return scaffold;
|
||||
}
|
||||
|
||||
Widget _buildCloseButton() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
minimum: const EdgeInsets.only(
|
||||
top: 16, // iOS standard margin
|
||||
right: 16, // iOS standard margin
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: translate('Close'),
|
||||
child: Container(
|
||||
width: 44, // iOS standard tap target size
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5), // Half transparency
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
clientClose(sessionId, _ffi);
|
||||
},
|
||||
child: Tooltip(
|
||||
message: translate('Close'),
|
||||
child: const Icon(
|
||||
Icons.chevron_left, // iOS-style back arrow
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingKeyboard() {
|
||||
|
||||
@@ -59,7 +59,8 @@ class CanvasCoords {
|
||||
model.scale = json['scale'];
|
||||
model.scrollX = json['scrollX'];
|
||||
model.scrollY = json['scrollY'];
|
||||
model.scrollStyle = ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
|
||||
model.scrollStyle =
|
||||
ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
|
||||
model.size = Size(json['size']['w'], json['size']['h']);
|
||||
return model;
|
||||
}
|
||||
@@ -418,6 +419,74 @@ class InputModel {
|
||||
});
|
||||
}
|
||||
|
||||
// https://github.com/flutter/flutter/issues/157241
|
||||
// Infer CapsLock state from the character output.
|
||||
// This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report
|
||||
// incorrect CapsLock state on iOS.
|
||||
bool _getIosCapsFromCharacter(KeyEvent e) {
|
||||
if (!isIOS) return false;
|
||||
final ch = e.character;
|
||||
return _getIosCapsFromCharacterImpl(
|
||||
ch, HardwareKeyboard.instance.isShiftPressed);
|
||||
}
|
||||
|
||||
// RawKeyEvent version of _getIosCapsFromCharacter.
|
||||
bool _getIosCapsFromRawCharacter(RawKeyEvent e) {
|
||||
if (!isIOS) return false;
|
||||
final ch = e.character;
|
||||
return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed);
|
||||
}
|
||||
|
||||
// Shared implementation for inferring CapsLock state from character.
|
||||
// Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É).
|
||||
//
|
||||
// Limitations:
|
||||
// 1. This inference assumes the client and server use the same keyboard layout.
|
||||
// If layouts differ (e.g., client uses EN, server uses DE), the character output
|
||||
// may not match expectations. For example, ';' on EN layout maps to 'ö' on DE
|
||||
// layout, making it impossible to correctly infer CapsLock state from the
|
||||
// character alone.
|
||||
// 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it
|
||||
// produces lowercase). This method cannot handle that case correctly.
|
||||
bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) {
|
||||
if (ch == null || ch.length != 1) return false;
|
||||
// Use Dart's built-in Unicode-aware case detection
|
||||
final upper = ch.toUpperCase();
|
||||
final lower = ch.toLowerCase();
|
||||
final isUpper = upper == ch && lower != ch;
|
||||
final isLower = lower == ch && upper != ch;
|
||||
// Skip non-letter characters (e.g., numbers, symbols, CJK characters without case)
|
||||
if (!isUpper && !isLower) return false;
|
||||
return isUpper != shiftPressed;
|
||||
}
|
||||
|
||||
int _buildLockModes(bool iosCapsLock) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (isIOS) {
|
||||
if (iosCapsLock) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
// Ignore "NumLock/ScrollLock" on iOS for now.
|
||||
} else {
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
}
|
||||
return lockModes;
|
||||
}
|
||||
|
||||
// This function must be called after the peer info is received.
|
||||
// Because `sessionGetKeyboardMode` relies on the peer version.
|
||||
updateKeyboardMode() async {
|
||||
@@ -550,6 +619,11 @@ class InputModel {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool iosCapsLock = false;
|
||||
if (isIOS && e is RawKeyDownEvent) {
|
||||
iosCapsLock = _getIosCapsFromRawCharacter(e);
|
||||
}
|
||||
|
||||
final key = e.logicalKey;
|
||||
if (e is RawKeyDownEvent) {
|
||||
if (!e.repeat) {
|
||||
@@ -586,7 +660,7 @@ class InputModel {
|
||||
|
||||
// * Currently mobile does not enable map mode
|
||||
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
|
||||
mapKeyboardModeRaw(e);
|
||||
mapKeyboardModeRaw(e, iosCapsLock);
|
||||
} else {
|
||||
legacyKeyboardModeRaw(e);
|
||||
}
|
||||
@@ -622,6 +696,11 @@ class InputModel {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool iosCapsLock = false;
|
||||
if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) {
|
||||
iosCapsLock = _getIosCapsFromCharacter(e);
|
||||
}
|
||||
|
||||
if (e is KeyUpEvent) {
|
||||
handleKeyUpEventModifiers(e);
|
||||
} else if (e is KeyDownEvent) {
|
||||
@@ -667,7 +746,8 @@ class InputModel {
|
||||
e.character ?? '',
|
||||
e.physicalKey.usbHidUsage & 0xFFFF,
|
||||
// Show repeat event be converted to "release+press" events?
|
||||
e is KeyDownEvent || e is KeyRepeatEvent);
|
||||
e is KeyDownEvent || e is KeyRepeatEvent,
|
||||
iosCapsLock);
|
||||
} else {
|
||||
legacyKeyboardMode(e);
|
||||
}
|
||||
@@ -676,23 +756,9 @@ class InputModel {
|
||||
}
|
||||
|
||||
/// Send Key Event
|
||||
void newKeyboardMode(String character, int usbHid, bool down) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
void newKeyboardMode(
|
||||
String character, int usbHid, bool down, bool iosCapsLock) {
|
||||
final lockModes = _buildLockModes(iosCapsLock);
|
||||
bind.sessionHandleFlutterKeyEvent(
|
||||
sessionId: sessionId,
|
||||
character: character,
|
||||
@@ -701,7 +767,7 @@ class InputModel {
|
||||
downOrUp: down);
|
||||
}
|
||||
|
||||
void mapKeyboardModeRaw(RawKeyEvent e) {
|
||||
void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
|
||||
int positionCode = -1;
|
||||
int platformCode = -1;
|
||||
bool down;
|
||||
@@ -732,27 +798,14 @@ class InputModel {
|
||||
} else {
|
||||
down = false;
|
||||
}
|
||||
inputRawKey(e.character ?? '', platformCode, positionCode, down);
|
||||
inputRawKey(
|
||||
e.character ?? '', platformCode, positionCode, down, iosCapsLock);
|
||||
}
|
||||
|
||||
/// Send raw Key Event
|
||||
void inputRawKey(String name, int platformCode, int positionCode, bool down) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
void inputRawKey(String name, int platformCode, int positionCode, bool down,
|
||||
bool iosCapsLock) {
|
||||
final lockModes = _buildLockModes(iosCapsLock);
|
||||
bind.sessionHandleFlutterRawKeyEvent(
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
@@ -826,6 +879,9 @@ class InputModel {
|
||||
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
|
||||
final Map<String, dynamic> out = {};
|
||||
|
||||
bool hasStaleButtonsOnMouseUp =
|
||||
type == _kMouseEventUp && evt.buttons == _lastButtons;
|
||||
|
||||
// Check update event type and set buttons to be sent.
|
||||
int buttons = _lastButtons;
|
||||
if (type == _kMouseEventMove) {
|
||||
@@ -850,7 +906,7 @@ class InputModel {
|
||||
buttons = evt.buttons;
|
||||
}
|
||||
}
|
||||
_lastButtons = evt.buttons;
|
||||
_lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
|
||||
|
||||
out['buttons'] = buttons;
|
||||
out['type'] = type;
|
||||
@@ -1218,6 +1274,28 @@ class InputModel {
|
||||
_trackpadLastDelta = Offset.zero;
|
||||
}
|
||||
|
||||
// iOS Magic Mouse duplicate event detection.
|
||||
// When using Magic Mouse on iPad, iOS may emit both mouse and touch events
|
||||
// for the same click in certain areas (like top-left corner).
|
||||
int _lastMouseDownTimeMs = 0;
|
||||
ui.Offset _lastMouseDownPos = ui.Offset.zero;
|
||||
|
||||
/// Check if a touch tap event should be ignored because it's a duplicate
|
||||
/// of a recent mouse event (iOS Magic Mouse issue).
|
||||
bool shouldIgnoreTouchTap(ui.Offset pos) {
|
||||
if (!isIOS) return false;
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final dt = nowMs - _lastMouseDownTimeMs;
|
||||
final distance = (_lastMouseDownPos - pos).distance;
|
||||
// If touch tap is within 2000ms and 80px of the last mouse down,
|
||||
// it's likely a duplicate event from the same Magic Mouse click.
|
||||
if (dt >= 0 && dt < 2000 && distance < 80.0) {
|
||||
debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void onPointDownImage(PointerDownEvent e) {
|
||||
debugPrint("onPointDownImage ${e.kind}");
|
||||
_stopFling = true;
|
||||
@@ -1227,6 +1305,13 @@ class InputModel {
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
|
||||
// Track mouse down events for duplicate detection on iOS.
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
if (e.kind == ui.PointerDeviceKind.mouse) {
|
||||
_lastMouseDownTimeMs = nowMs;
|
||||
_lastMouseDownPos = e.position;
|
||||
}
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
@@ -1768,9 +1853,9 @@ class InputModel {
|
||||
// Simulate a key press event.
|
||||
// `usbHidUsage` is the USB HID usage code of the key.
|
||||
Future<void> tapHidKey(int usbHidUsage) async {
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
|
||||
}
|
||||
|
||||
Future<void> onMobileVolumeUp() async =>
|
||||
|
||||
135
res/REGENERATE_ICONS.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Icon Regeneration Guide
|
||||
|
||||
This document describes how to regenerate app icons from the source icon files.
|
||||
|
||||
## Source Icons
|
||||
|
||||
- **Primary source**: `res/icon.png` (2048x2048 PNG) - Used for iOS and Android
|
||||
- **SVG source**: `flutter/assets/icon.svg` - Vector format, can be used to generate any resolution
|
||||
- **macOS source**: `res/mac-icon.png` - Specific macOS variant
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install required tools:
|
||||
```bash
|
||||
sudo apt-get install librsvg2-bin imagemagick
|
||||
```
|
||||
|
||||
## Regenerating iOS Icons
|
||||
|
||||
### Method 1: Using ImageMagick (Manual)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
SOURCE_ICON="res/icon.png"
|
||||
OUTPUT_DIR="flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
# Function to generate icon with alpha removal for iOS
|
||||
generate_icon() {
|
||||
local size=$1
|
||||
local filename=$2
|
||||
convert "$SOURCE_ICON" -resize ${size}x${size} -background white -alpha remove -alpha off "$OUTPUT_DIR/$filename"
|
||||
}
|
||||
|
||||
# iPhone icons
|
||||
generate_icon 40 "Icon-App-20x20@2x.png"
|
||||
generate_icon 60 "Icon-App-20x20@3x.png"
|
||||
generate_icon 29 "Icon-App-29x29@1x.png"
|
||||
generate_icon 58 "Icon-App-29x29@2x.png"
|
||||
generate_icon 87 "Icon-App-29x29@3x.png"
|
||||
generate_icon 80 "Icon-App-40x40@2x.png"
|
||||
generate_icon 120 "Icon-App-40x40@3x.png"
|
||||
generate_icon 120 "Icon-App-60x60@2x.png"
|
||||
generate_icon 180 "Icon-App-60x60@3x.png"
|
||||
|
||||
# iPad icons
|
||||
generate_icon 20 "Icon-App-20x20@1x.png"
|
||||
generate_icon 40 "Icon-App-40x40@1x.png"
|
||||
generate_icon 76 "Icon-App-76x76@1x.png"
|
||||
generate_icon 152 "Icon-App-76x76@2x.png"
|
||||
generate_icon 167 "Icon-App-83.5x83.5@2x.png"
|
||||
|
||||
# App Store icon
|
||||
generate_icon 1024 "Icon-App-1024x1024@1x.png"
|
||||
```
|
||||
|
||||
### Method 2: Using flutter_launcher_icons (Automated)
|
||||
|
||||
```bash
|
||||
cd flutter
|
||||
flutter pub run flutter_launcher_icons
|
||||
```
|
||||
|
||||
This uses the configuration in `flutter/pubspec.yaml`:
|
||||
```yaml
|
||||
flutter_icons:
|
||||
image_path: "../res/icon.png"
|
||||
remove_alpha_ios: true
|
||||
ios: true
|
||||
android: true
|
||||
```
|
||||
|
||||
## Regenerating Android Icons
|
||||
|
||||
```bash
|
||||
SOURCE_ICON="res/icon.png"
|
||||
BASE_DIR="flutter/android/app/src/main/res"
|
||||
|
||||
convert "$SOURCE_ICON" -resize 48x48 "$BASE_DIR/mipmap-mdpi/ic_launcher.png"
|
||||
convert "$SOURCE_ICON" -resize 72x72 "$BASE_DIR/mipmap-hdpi/ic_launcher.png"
|
||||
convert "$SOURCE_ICON" -resize 96x96 "$BASE_DIR/mipmap-xhdpi/ic_launcher.png"
|
||||
convert "$SOURCE_ICON" -resize 144x144 "$BASE_DIR/mipmap-xxhdpi/ic_launcher.png"
|
||||
convert "$SOURCE_ICON" -resize 192x192 "$BASE_DIR/mipmap-xxxhdpi/ic_launcher.png"
|
||||
```
|
||||
|
||||
Or use `flutter_launcher_icons` as shown above.
|
||||
|
||||
## Updating Source Icon from SVG
|
||||
|
||||
If you need to regenerate the source PNG from the SVG at a different resolution:
|
||||
|
||||
```bash
|
||||
# Generate 2048x2048 PNG from SVG
|
||||
rsvg-convert -w 2048 -h 2048 flutter/assets/icon.svg -o res/icon.png
|
||||
|
||||
# Or for even higher resolution (4096x4096)
|
||||
rsvg-convert -w 4096 -h 4096 flutter/assets/icon.svg -o res/icon.png
|
||||
```
|
||||
|
||||
## iOS Icon Requirements
|
||||
|
||||
- All iOS icons must have alpha channel removed (opaque)
|
||||
- White background is applied to maintain appearance
|
||||
- Icons are automatically rounded by iOS system
|
||||
- Retina displays require @2x and @3x variants
|
||||
|
||||
## Icon Sizes
|
||||
|
||||
### iOS
|
||||
- 20pt (20x20, 40x40, 60x60)
|
||||
- 29pt (29x29, 58x58, 87x87)
|
||||
- 40pt (40x40, 80x80, 120x120)
|
||||
- 60pt (120x120, 180x180)
|
||||
- 76pt (76x76, 152x152) - iPad
|
||||
- 83.5pt (167x167) - iPad Pro
|
||||
- 1024x1024 - App Store
|
||||
|
||||
### Android
|
||||
- mdpi: 48x48
|
||||
- hdpi: 72x72
|
||||
- xhdpi: 96x96
|
||||
- xxhdpi: 144x144
|
||||
- xxxhdpi: 192x192
|
||||
|
||||
## Quality Tips
|
||||
|
||||
1. Always start from the highest resolution source available (SVG preferred)
|
||||
2. Use at least 2048x2048 PNG as intermediate source
|
||||
3. Let ImageMagick/flutter_launcher_icons handle downscaling
|
||||
4. Never upscale low-resolution images - regenerate from vector source
|
||||
5. Test on actual devices with Retina displays to verify quality
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Human Interface Guidelines - App Icons](https://developer.apple.com/design/human-interface-guidelines/app-icons)
|
||||
- [Android App Icon Guidelines](https://developer.android.com/guide/practices/ui_guidelines/icon_design_launcher)
|
||||
BIN
res/icon.png
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 188 KiB |
@@ -729,15 +729,15 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("server-oss-not-support-tip", "注意:RustDesk 開源伺服器 (OSS server) 不包含此功能。"),
|
||||
("input note here", "輸入備註"),
|
||||
("note-at-conn-end-tip", "在連接結束時請求備註"),
|
||||
("Show terminal extra keys", ""),
|
||||
("Relative mouse mode", ""),
|
||||
("rel-mouse-not-supported-peer-tip", ""),
|
||||
("rel-mouse-not-ready-tip", ""),
|
||||
("rel-mouse-lock-failed-tip", ""),
|
||||
("rel-mouse-exit-{}-tip", ""),
|
||||
("rel-mouse-permission-lost-tip", ""),
|
||||
("Changelog", ""),
|
||||
("keep-awake-during-outgoing-sessions-label", ""),
|
||||
("keep-awake-during-incoming-sessions-label", ""),
|
||||
("Show terminal extra keys", "顯示終端機額外按鍵"),
|
||||
("Relative mouse mode", "相對滑鼠模式"),
|
||||
("rel-mouse-not-supported-peer-tip", "被控端不支援相對滑鼠模式"),
|
||||
("rel-mouse-not-ready-tip", "相對滑鼠模式尚未就緒,請稍候再試"),
|
||||
("rel-mouse-lock-failed-tip", "無法鎖定游標,相對滑鼠模式已停用"),
|
||||
("rel-mouse-exit-{}-tip", "按下 {} 退出"),
|
||||
("rel-mouse-permission-lost-tip", "鍵盤權限被撤銷,相對滑鼠模式已被停用"),
|
||||
("Changelog", "更新日誌"),
|
||||
("keep-awake-during-outgoing-sessions-label", "在連出工作階段期間保持螢幕喚醒"),
|
||||
("keep-awake-during-incoming-sessions-label", "在連入工作階段期間保持螢幕喚醒"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -357,27 +357,6 @@ static std::string GetDisplayUUID(CGDirectDisplayID displayId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Helper function to get display name from DisplayID
|
||||
static std::string GetDisplayName(CGDirectDisplayID displayId) {
|
||||
NSArray<NSScreen *> *screens = [NSScreen screens];
|
||||
for (NSScreen *screen in screens) {
|
||||
NSDictionary *deviceDescription = [screen deviceDescription];
|
||||
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
|
||||
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
|
||||
if (screenDisplayID == displayId) {
|
||||
// localizedName is available on macOS 10.15+
|
||||
if (@available(macOS 10.15, *)) {
|
||||
NSString *name = [screen localizedName];
|
||||
if (name) {
|
||||
return std::string([name UTF8String]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Helper function to find DisplayID by UUID from current online displays
|
||||
static CGDirectDisplayID FindDisplayIdByUUID(const std::string& targetUuid) {
|
||||
uint32_t count = 0;
|
||||
@@ -415,9 +394,7 @@ static bool RestoreAllGammas() {
|
||||
const CGGammaValue* blue = green + sampleCount;
|
||||
CGError error = CGSetDisplayTransferByTable(d, sampleCount, red, green, blue);
|
||||
if (error != kCGErrorSuccess) {
|
||||
std::string displayName = GetDisplayName(d);
|
||||
NSLog(@"Failed to restore gamma for display (Name: %s, ID: %u, UUID: %s, error: %d)",
|
||||
displayName.c_str(), (unsigned)d, uuid.c_str(), error);
|
||||
NSLog(@"Failed to restore gamma for display (ID: %u, UUID: %s, error: %d)", (unsigned)d, uuid.c_str(), error);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
@@ -897,8 +874,7 @@ extern "C" bool MacSetPrivacyMode(bool on) {
|
||||
blackoutAttemptCount++;
|
||||
CGError error = CGSetDisplayTransferByTable(d, capacity, zeros.data(), zeros.data(), zeros.data());
|
||||
if (error != kCGErrorSuccess) {
|
||||
std::string displayName = GetDisplayName(d);
|
||||
NSLog(@"MacSetPrivacyMode: Failed to blackout display (Name: %s, ID: %u, UUID: %s, error: %d)", displayName.c_str(), (unsigned)d, uuid.c_str(), error);
|
||||
NSLog(@"MacSetPrivacyMode: Failed to blackout display (ID: %u, UUID: %s, error: %d)", (unsigned)d, uuid.c_str(), error);
|
||||
} else {
|
||||
blackoutSuccessCount++;
|
||||
}
|
||||
|
||||