mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-20 19:14:39 +08:00
[feat] add iOS system keyboard input and improve computer keyboard
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2023-12-14
|
||||
* Copyright (c) 2023 by DI JUNKUN, All Rights Reserved.
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DEVICE_CONTROLLER_H_
|
||||
@@ -48,6 +48,8 @@ class KeyboardCapturer : public DeviceController {
|
||||
// Permission-denied backends can wait for an OS grant notification rather
|
||||
// than repeatedly releasing held fallback keys to retry an impossible hook.
|
||||
virtual bool ShouldRetryHook() const { return true; }
|
||||
virtual bool SupportsTextInput() const { return false; }
|
||||
virtual int SendTextCommand(const std::string&) { return -1; }
|
||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code = 0,
|
||||
bool extended = false) = 0;
|
||||
|
||||
@@ -733,6 +733,14 @@ void SessionDeviceManager::DrainCapturedKeyboardInput() {
|
||||
}
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::SupportsTextInput() const {
|
||||
return keyboard_capturer_ && keyboard_capturer_->SupportsTextInput();
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::SendTextCommand(const std::string& text) {
|
||||
return keyboard_capturer_ && keyboard_capturer_->SendTextCommand(text) == 0;
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code,
|
||||
bool extended) {
|
||||
|
||||
@@ -73,6 +73,8 @@ public:
|
||||
void ClearAudio(const std::string& remote_id);
|
||||
void PushAudio(const std::string& remote_id, const char *data, size_t size);
|
||||
|
||||
bool SupportsTextInput() const;
|
||||
bool SendTextCommand(const std::string& text);
|
||||
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
|
||||
bool extended);
|
||||
bool SendMouseCommand(const RemoteAction &action, int selected_display);
|
||||
|
||||
@@ -268,6 +268,15 @@ void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
|
||||
}
|
||||
}
|
||||
|
||||
void KeyboardController::ApplyRemoteText(const std::string& remote_id,
|
||||
const std::string& text) {
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
if (!owner_.IsAuthorizedController(remote_id) ||
|
||||
!owner_.devices_.SupportsTextInput()) return;
|
||||
if (!owner_.devices_.SendTextCommand(text))
|
||||
LOG_WARN("Failed to inject committed remote text");
|
||||
}
|
||||
|
||||
void KeyboardController::ApplyRemoteState(const std::string& remote_id,
|
||||
const RemoteAction& action) {
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
|
||||
@@ -27,6 +27,7 @@ public:
|
||||
void SendHeartbeat(bool force);
|
||||
void ApplyRemoteEvent(const std::string &remote_id,
|
||||
const RemoteAction &remote_action);
|
||||
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
|
||||
void ApplyRemoteState(const std::string &remote_id,
|
||||
const RemoteAction &remote_action);
|
||||
void ReleaseRemotePressedKeys(const std::string &remote_id,
|
||||
|
||||
@@ -280,6 +280,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::text_input) {
|
||||
// Share the ordered keyboard stream with delete/return and shortcuts.
|
||||
if (source_id == runtime->keyboard_label_ &&
|
||||
runtime->IsAuthorizedController(remote_id))
|
||||
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text);
|
||||
return;
|
||||
}
|
||||
const bool operates_host = remote_action.type == ControlType::mouse ||
|
||||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
|
||||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
|
||||
|
||||
@@ -295,6 +295,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
/*supports_privacy_screen=*/!runtime->unattended_host_,
|
||||
CROSSDESK_VERSION, kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
|
||||
remote_action.i.supports_deskhub_preview = true;
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
remote_action.i.supports_deskhub_fps15 = true;
|
||||
@@ -457,6 +458,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
/*supports_privacy_screen=*/!runtime->unattended_host_,
|
||||
CROSSDESK_VERSION, kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
|
||||
remote_action.i.supports_deskhub_preview = true;
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
remote_action.i.supports_deskhub_fps15 = true;
|
||||
|
||||
@@ -331,4 +331,48 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
|
||||
|
||||
return 0;
|
||||
}
|
||||
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
|
||||
if (text.empty() || text.size() > kMaxTextInputBytes) return -1;
|
||||
CFStringRef string = CFStringCreateWithBytes(
|
||||
kCFAllocatorDefault, reinterpret_cast<const UInt8*>(text.data()),
|
||||
text.size(), kCFStringEncodingUTF8, false);
|
||||
if (!string) return -1;
|
||||
const CFIndex length = CFStringGetLength(string);
|
||||
std::vector<UniChar> characters(length);
|
||||
CFStringGetCharacters(string, CFRangeMake(0, length), characters.data());
|
||||
CFRelease(string);
|
||||
for (CFIndex index = 0; index < length;) {
|
||||
const UniChar character = characters[index];
|
||||
if (character == '\n' || character == '\r' || character == '\t') {
|
||||
const int key = character == '\t' ? 0x09 : 0x0D;
|
||||
SendKeyboardCommand(key, true, 0, false);
|
||||
SendKeyboardCommand(key, false, 0, false);
|
||||
++index;
|
||||
if (character == '\r' && index < length && characters[index] == '\n') ++index;
|
||||
continue;
|
||||
}
|
||||
// Keep surrogate pairs together. Separate events let apps handle long
|
||||
// dictation results without CGEvent's small inline Unicode buffer limit.
|
||||
const CFIndex count = CFStringIsSurrogateHighCharacter(character) &&
|
||||
index + 1 < length ? 2 : 1;
|
||||
CGEventRef down = CGEventCreateKeyboardEvent(nullptr, 0, true);
|
||||
CGEventRef up = CGEventCreateKeyboardEvent(nullptr, 0, false);
|
||||
if (!down || !up) {
|
||||
if (down) CFRelease(down);
|
||||
if (up) CFRelease(up);
|
||||
return -1;
|
||||
}
|
||||
for (CGEventRef event : {down, up}) {
|
||||
CGEventSetFlags(event, 0);
|
||||
CGEventKeyboardSetUnicodeString(event, count, characters.data() + index);
|
||||
CGEventSetIntegerValueField(event, kCGEventSourceUserData,
|
||||
kCrossDeskInjectedEvent);
|
||||
CGEventPost(kCGHIDEventTap, event);
|
||||
CFRelease(event);
|
||||
}
|
||||
index += count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2024-11-22
|
||||
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _KEYBOARD_CAPTURER_H_
|
||||
@@ -22,6 +22,8 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer {
|
||||
public:
|
||||
virtual int Hook(OnKeyAction on_key_action, void* user_ptr);
|
||||
virtual int Unhook();
|
||||
bool SupportsTextInput() const override { return true; }
|
||||
int SendTextCommand(const std::string& text) override;
|
||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code = 0,
|
||||
bool extended = false);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
|
||||
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
|
||||
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
|
||||
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
|
||||
@@ -36,6 +37,7 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
|
||||
@@ -153,6 +155,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
20000000000000000000000A /* RemoteTouchInputView.swift */,
|
||||
200000000000000000000030 /* RemoteSystemKeyboard.swift */,
|
||||
20000000000000000000001D /* RemoteMouseInput.swift */,
|
||||
20000000000000000000001E /* VirtualMouseBar.swift */,
|
||||
);
|
||||
@@ -295,6 +298,7 @@
|
||||
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
|
||||
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
|
||||
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
|
||||
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */,
|
||||
10000000000000000000001D /* RemoteMouseInput.swift in Sources */,
|
||||
10000000000000000000001E /* VirtualMouseBar.swift in Sources */,
|
||||
100000000000000000000012 /* PrivacyPreferences.swift in Sources */,
|
||||
|
||||
@@ -89,7 +89,8 @@ typedef struct {
|
||||
platform:(NSString *)platform
|
||||
displayNames:(NSArray<NSString *> *)displayNames
|
||||
displaySizes:(NSArray<NSValue *> *)displaySizes
|
||||
supportsVideoSettings:(BOOL)supportsVideoSettings;
|
||||
supportsVideoSettings:(BOOL)supportsVideoSettings
|
||||
supportsTextInput:(BOOL)supportsTextInput;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveVideoSettingsQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
@@ -179,6 +180,7 @@ typedef struct {
|
||||
deltaY:(NSInteger)deltaY;
|
||||
- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown;
|
||||
- (void)sendText:(NSString *)text;
|
||||
- (void)sendCommittedText:(NSString *)text;
|
||||
- (void)switchToDisplay:(NSInteger)displayIndex;
|
||||
- (void)setAudioEnabled:(BOOL)enabled;
|
||||
- (void)sendClipboardText:(NSString *)text;
|
||||
|
||||
@@ -887,8 +887,10 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!utf8.length) return;
|
||||
const std::string bytes(static_cast<const char *>(utf8.bytes), utf8.length);
|
||||
const uint64_t generation = _activeControllerGeneration.load();
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (!self->_state->controller_peer) return;
|
||||
if (![self isControllerGenerationActive:generation] ||
|
||||
!self->_state->controller_peer) return;
|
||||
for (unsigned char character : bytes) {
|
||||
KeyMapping mapping = MapASCII(character);
|
||||
if (!mapping.valid) continue;
|
||||
@@ -913,6 +915,16 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
});
|
||||
}
|
||||
|
||||
- (void)sendCommittedText:(NSString *)text {
|
||||
NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!utf8.length || utf8.length > crossdesk::kMaxTextInputBytes ||
|
||||
std::memchr(utf8.bytes, '\0', utf8.length)) return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::text_input;
|
||||
action.text.assign(static_cast<const char *>(utf8.bytes), utf8.length);
|
||||
[self sendMessage:action.to_json() reliable:YES stream:kKeyboardStream];
|
||||
}
|
||||
|
||||
- (void)switchToDisplay:(NSInteger)displayIndex {
|
||||
if (displayIndex < 0 || displayIndex >= 8) return;
|
||||
[self resetVideoDelivery];
|
||||
@@ -1059,8 +1071,10 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
// std::strings.
|
||||
const std::string owned_message(message);
|
||||
const std::string owned_stream(stream ? stream : "");
|
||||
const uint64_t generation = _activeControllerGeneration.load();
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (!self->_state->controller_peer) return;
|
||||
if (![self isControllerGenerationActive:generation] ||
|
||||
!self->_state->controller_peer) return;
|
||||
if (reliable) {
|
||||
SendReliableDataFrame(self->_state->controller_peer,
|
||||
owned_message.data(), owned_message.size(),
|
||||
@@ -1697,6 +1711,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
if (action.type != ControlType::host_infomation) return;
|
||||
const BOOL supports_video_settings = action.i.supports_video_settings;
|
||||
const BOOL supports_text_input = action.i.supports_text_input;
|
||||
NSString *platform = [NSString stringWithUTF8String:
|
||||
crossdesk::HostPlatformName(action.i.platform)];
|
||||
NSString *host_name = [[NSString alloc]
|
||||
@@ -1729,14 +1744,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didReceiveHostName:appVersion:platform:displayNames:
|
||||
displaySizes:supportsVideoSettings:)]) {
|
||||
displaySizes:supportsVideoSettings:supportsTextInput:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didReceiveHostName:host_name ?: @""
|
||||
appVersion:app_version ?: @""
|
||||
platform:platform
|
||||
displayNames:display_names
|
||||
displaySizes:display_sizes
|
||||
supportsVideoSettings:supports_video_settings];
|
||||
supportsVideoSettings:supports_video_settings
|
||||
supportsTextInput:supports_text_input];
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// UIKit owns composition, candidate selection, keyboard switching and dictation.
|
||||
/// Confirmed text is mirrored to the remote app; preedit stays in the IME.
|
||||
struct RemoteSystemKeyboard: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@StateObject private var keyboard = RemoteKeyboardController()
|
||||
var inputContext: RemoteKeyboardInputContext? = nil
|
||||
var geometry: RemoteKeyboardGeometry? = nil
|
||||
var animatePresentation = true
|
||||
var didPresent: () -> Void = {}
|
||||
let switchKeyboard: () -> Void
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
RemoteKeyboardHost(
|
||||
controller: keyboard,
|
||||
edit: session.sendSystemTextEdit,
|
||||
inputContext: inputContext,
|
||||
geometry: geometry,
|
||||
animatePresentation: animatePresentation,
|
||||
didPresent: didPresent,
|
||||
key: session.sendKeyStroke,
|
||||
status: session.keyboardInputStatus,
|
||||
switchKeyboard: switchKeyboard,
|
||||
dismiss: dismiss
|
||||
)
|
||||
// The native host tracks the keyboard itself, including its animation.
|
||||
// SwiftUI must not also resize it to the keyboard's safe area.
|
||||
.ignoresSafeArea()
|
||||
.onDisappear { keyboard.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemoteKeyboardHost: UIViewControllerRepresentable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let controller: RemoteKeyboardController
|
||||
let edit: (Int, String) -> Bool
|
||||
let inputContext: RemoteKeyboardInputContext?
|
||||
let geometry: RemoteKeyboardGeometry?
|
||||
let animatePresentation: Bool
|
||||
let didPresent: () -> Void
|
||||
let key: (UInt) -> Void
|
||||
let status: String
|
||||
let switchKeyboard: () -> Void
|
||||
let dismiss: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> RemoteKeyboardController {
|
||||
update(controller)
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: RemoteKeyboardController,
|
||||
context: Context) {
|
||||
update(controller)
|
||||
}
|
||||
|
||||
private func update(_ controller: RemoteKeyboardController) {
|
||||
controller.editor.applyEdit = edit
|
||||
inputContext?.editor = controller.editor
|
||||
controller.geometry = geometry
|
||||
controller.animatePresentation = animatePresentation
|
||||
controller.didPresent = didPresent
|
||||
controller.editor.key = key
|
||||
controller.inputBar.switchKeyboard = { [weak controller] in
|
||||
controller?.rememberKeyboardHeight()
|
||||
// Let SwiftUI install the replacement panel before teardown hides
|
||||
// the native keyboard, avoiding an empty frame between the two.
|
||||
controller?.animateDismissal = false
|
||||
switchKeyboard()
|
||||
}
|
||||
controller.inputBar.dismiss = { [weak controller] in
|
||||
controller?.stop()
|
||||
dismiss()
|
||||
}
|
||||
controller.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light
|
||||
controller.inputBar.setStatus(status)
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ controller: RemoteKeyboardController,
|
||||
coordinator: ()) {
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteKeyboardController: UIViewController, ObservableObject {
|
||||
let editor: RemoteTextInputView
|
||||
let inputBar: RemoteKeyboardInputBar
|
||||
var geometry: RemoteKeyboardGeometry?
|
||||
var animatePresentation = true
|
||||
var animateDismissal = true
|
||||
var didPresent: (() -> Void)?
|
||||
private var acceptingInput = true
|
||||
private var animationsEnabledBeforePresentation: Bool?
|
||||
|
||||
init() {
|
||||
let editor = RemoteTextInputView()
|
||||
editor.automaticallyFocus = false
|
||||
self.editor = editor
|
||||
self.inputBar = RemoteKeyboardInputBar()
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func loadView() {
|
||||
let host = RemoteKeyboardHostView()
|
||||
host.willDetach = { [weak self] in self?.stop() }
|
||||
view = host
|
||||
view.backgroundColor = .clear
|
||||
host.inputBar = inputBar
|
||||
inputBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.addSubview(inputBar)
|
||||
// Keep a real UITextField for IME and dictation context, but never
|
||||
// display a second copy of the remote text or a local caret.
|
||||
editor.isAccessibilityElement = false
|
||||
editor.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.addSubview(editor)
|
||||
|
||||
// Keep one continuous background behind the row and the system
|
||||
// keyboard's rounded corners, without reserving an input accessory slot.
|
||||
let backdrop = UIView()
|
||||
backdrop.backgroundColor = RemoteKeyboardInputBar.keyboardBackground
|
||||
backdrop.isUserInteractionEnabled = false
|
||||
backdrop.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.insertSubview(backdrop, at: 0)
|
||||
NSLayoutConstraint.activate([
|
||||
editor.leadingAnchor.constraint(equalTo: inputBar.leadingAnchor),
|
||||
editor.topAnchor.constraint(equalTo: inputBar.topAnchor),
|
||||
editor.widthAnchor.constraint(equalToConstant: 1),
|
||||
editor.heightAnchor.constraint(equalToConstant: 1),
|
||||
inputBar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
inputBar.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
inputBar.bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor),
|
||||
backdrop.topAnchor.constraint(equalTo: inputBar.topAnchor),
|
||||
backdrop.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
backdrop.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
backdrop.bottomAnchor.constraint(equalTo: host.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard acceptingInput, !editor.isFirstResponder else { return }
|
||||
if animatePresentation {
|
||||
editor.becomeFirstResponder()
|
||||
} else {
|
||||
// UIKit may start showing the keyboard after becomeFirstResponder
|
||||
// returns. Keep this short suppression alive until it is shown,
|
||||
// rather than ending it at the end of a synchronous animation block.
|
||||
animationsEnabledBeforePresentation = UIView.areAnimationsEnabled
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(finishKeyboardPresentation),
|
||||
name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
UIView.setAnimationsEnabled(false)
|
||||
if !editor.becomeFirstResponder() {
|
||||
finishKeyboardPresentation()
|
||||
}
|
||||
view.layoutIfNeeded()
|
||||
// A hardware keyboard or an interrupted focus request may produce
|
||||
// no did-show notification. Always restore the previous state.
|
||||
// Retain the controller until cleanup even if its host disappears.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.finishKeyboardPresentation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func finishKeyboardPresentation() {
|
||||
guard let enabled = animationsEnabledBeforePresentation else { return }
|
||||
animationsEnabledBeforePresentation = nil
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
UIView.setAnimationsEnabled(enabled)
|
||||
if acceptingInput {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.acceptingInput else { return }
|
||||
self.didPresent?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
rememberKeyboardHeight()
|
||||
}
|
||||
|
||||
func rememberKeyboardHeight() {
|
||||
guard acceptingInput, editor.isFirstResponder else { return }
|
||||
let keyboard = view.keyboardLayoutGuide.layoutFrame
|
||||
// Ignore hidden, floating and split keyboards: they do not define a
|
||||
// full-width docked panel that the computer keyboard can replace.
|
||||
guard keyboard.height > 100,
|
||||
keyboard.width >= view.bounds.width - 1 else { return }
|
||||
geometry?.remember(height: view.bounds.maxY - keyboard.minY +
|
||||
RemoteKeyboardInputBar.barHeight,
|
||||
size: view.bounds.size)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard acceptingInput else { return }
|
||||
acceptingInput = false
|
||||
finishKeyboardPresentation()
|
||||
editor.stop()
|
||||
inputBar.switchKeyboard = nil
|
||||
inputBar.dismiss = nil
|
||||
let dismiss = {
|
||||
self.editor.resignFirstResponder()
|
||||
self.editor.removeFromSuperview()
|
||||
}
|
||||
if animateDismissal {
|
||||
dismiss()
|
||||
} else {
|
||||
UIView.performWithoutAnimation(dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain the last native height for each window width and orientation, even
|
||||
/// after the text field resigns first responder during a keyboard switch.
|
||||
final class RemoteKeyboardGeometry: ObservableObject {
|
||||
private var heights: [String: CGFloat] = [:]
|
||||
|
||||
private func key(for size: CGSize) -> String {
|
||||
"\(Int(size.width.rounded()))-\(size.width > size.height)"
|
||||
}
|
||||
|
||||
func remember(height: CGFloat, size: CGSize) {
|
||||
heights[key(for: size)] = height
|
||||
}
|
||||
|
||||
func panelHeight(in size: CGSize, bottomInset: CGFloat) -> CGFloat {
|
||||
// A rotation may reach a size at which the native keyboard has not yet
|
||||
// appeared. The compact iPhone keybed is 161 points; using the full
|
||||
// 216-point keybed here makes only the first landscape switch jump.
|
||||
// Safe-area and toolbar heights are added separately below.
|
||||
let fallback: CGFloat = size.width > size.height ? 161 : 300
|
||||
let height = heights[key(for: size)] ??
|
||||
(fallback + bottomInset + RemoteKeyboardInputBar.barHeight)
|
||||
return min(height, size.height)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RemoteKeyboardHostView: UIView {
|
||||
var willDetach: (() -> Void)?
|
||||
weak var inputBar: UIView?
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard let inputBar else { return nil }
|
||||
// Video gestures outside the input row must reach the remote viewport.
|
||||
return inputBar.hitTest(convert(point, to: inputBar), with: event)
|
||||
}
|
||||
|
||||
override func willMove(toSuperview newSuperview: UIView?) {
|
||||
if newSuperview == nil { willDetach?() }
|
||||
super.willMove(toSuperview: newSuperview)
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the same UIKit symbol layout in both the native and SwiftUI toolbars.
|
||||
/// Matching font sizes alone still gives their symbols different baselines.
|
||||
struct RemoteKeyboardDismissButton: UIViewRepresentable {
|
||||
static let sideLength: CGFloat = 44
|
||||
let accessibilityLabel: String
|
||||
let action: () -> Void
|
||||
|
||||
static func makeButton() -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.tintColor = .label
|
||||
button.setImage(UIImage(systemName: "keyboard.chevron.compact.down"), for: .normal)
|
||||
button.setPreferredSymbolConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 20, weight: .regular, scale: .medium),
|
||||
forImageIn: .normal)
|
||||
button.contentHorizontalAlignment = .center
|
||||
button.contentVerticalAlignment = .center
|
||||
return button
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(action: action) }
|
||||
|
||||
func makeUIView(context: Context) -> UIButton {
|
||||
let button = Self.makeButton()
|
||||
button.addTarget(context.coordinator, action: #selector(Coordinator.pressed),
|
||||
for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
func updateUIView(_ button: UIButton, context: Context) {
|
||||
button.accessibilityLabel = accessibilityLabel
|
||||
context.coordinator.action = action
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
var action: () -> Void
|
||||
|
||||
init(action: @escaping () -> Void) { self.action = action }
|
||||
|
||||
@objc func pressed() { action() }
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteKeyboardInputBar: UIView {
|
||||
static let barHeight: CGFloat = 44
|
||||
static let keyboardBackground = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? .secondarySystemBackground : .systemGray4
|
||||
}
|
||||
var switchKeyboard: (() -> Void)?
|
||||
var dismiss: (() -> Void)?
|
||||
private let statusLabel = UILabel()
|
||||
private let row = UIStackView()
|
||||
private var status = ""
|
||||
private var statusHeight: CGFloat = 0
|
||||
private var heightConstraint: NSLayoutConstraint!
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .clear
|
||||
tintColor = .label
|
||||
heightConstraint = heightAnchor.constraint(equalToConstant: Self.barHeight)
|
||||
heightConstraint.isActive = true
|
||||
|
||||
let computerButton = UIButton(type: .system)
|
||||
computerButton.setTitle("电脑键盘", for: .normal)
|
||||
computerButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
computerButton.accessibilityLabel = "切换到电脑键盘"
|
||||
computerButton.addTarget(self, action: #selector(switchToComputer), for: .touchUpInside)
|
||||
|
||||
let dismissButton = RemoteKeyboardDismissButton.makeButton()
|
||||
dismissButton.accessibilityLabel = "收起系统键盘"
|
||||
dismissButton.addTarget(self, action: #selector(hideKeyboard), for: .touchUpInside)
|
||||
|
||||
row.axis = .horizontal
|
||||
row.alignment = .fill
|
||||
row.spacing = 4
|
||||
for item in [computerButton, UIView(), dismissButton] { row.addArrangedSubview(item) }
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(row)
|
||||
NSLayoutConstraint.activate([
|
||||
computerButton.widthAnchor.constraint(equalToConstant: 80),
|
||||
dismissButton.widthAnchor.constraint(equalToConstant: RemoteKeyboardDismissButton.sideLength),
|
||||
row.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4),
|
||||
row.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4),
|
||||
row.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
row.heightAnchor.constraint(equalToConstant: Self.barHeight)
|
||||
])
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12)
|
||||
statusLabel.textColor = .secondaryLabel
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.isHidden = true
|
||||
addSubview(statusLabel)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: Self.barHeight + statusHeight)
|
||||
}
|
||||
|
||||
func setStatus(_ value: String) {
|
||||
guard status != value else { return }
|
||||
status = value
|
||||
statusLabel.text = value
|
||||
statusLabel.isHidden = value.isEmpty
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let inset = max(8, safeAreaInsets.left)
|
||||
let width = max(1, bounds.width - inset - max(8, safeAreaInsets.right))
|
||||
let height = status.isEmpty ? 0 : ceil(statusLabel.sizeThatFits(
|
||||
CGSize(width: width, height: .greatestFiniteMagnitude)).height) + 4
|
||||
statusLabel.frame = CGRect(x: inset, y: 2, width: width, height: max(0, height - 4))
|
||||
if statusHeight != height {
|
||||
statusHeight = height
|
||||
heightConstraint.constant = Self.barHeight + height
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func switchToComputer() { switchKeyboard?() }
|
||||
@objc private func hideKeyboard() { dismiss?() }
|
||||
}
|
||||
|
||||
/// Resets the local IME context synchronously before a remote click can move
|
||||
/// the insertion point. Nothing typed at the old location may be rewritten there.
|
||||
final class RemoteKeyboardInputContext: ObservableObject {
|
||||
weak var editor: RemoteTextInputView?
|
||||
|
||||
func remoteFocusWillChange() {
|
||||
editor?.finishInputContext()
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteTextInputView: UITextField, UITextFieldDelegate {
|
||||
var applyEdit: ((Int, String) -> Bool)?
|
||||
var key: ((UInt) -> Void)?
|
||||
private(set) var acceptingInput = true
|
||||
var automaticallyFocus = true
|
||||
private var sentText = ""
|
||||
private var changeScheduled = false
|
||||
private var resetting = false
|
||||
private var generation: UInt64 = 0
|
||||
|
||||
override var canBecomeFirstResponder: Bool { acceptingInput && super.canBecomeFirstResponder }
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
delegate = self
|
||||
textColor = .clear
|
||||
tintColor = .clear
|
||||
backgroundColor = .clear
|
||||
borderStyle = .none
|
||||
keyboardType = .default
|
||||
returnKeyType = .default
|
||||
autocorrectionType = .default
|
||||
autocapitalizationType = .none
|
||||
spellCheckingType = .default
|
||||
smartQuotesType = .no
|
||||
smartDashesType = .no
|
||||
smartInsertDeleteType = .no
|
||||
inputAssistantItem.leadingBarButtonGroups = []
|
||||
inputAssistantItem.trailingBarButtonGroups = []
|
||||
addTarget(self, action: #selector(scheduleTextChange), for: .editingChanged)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
if window != nil && acceptingInput && automaticallyFocus { becomeFirstResponder() }
|
||||
}
|
||||
|
||||
override func caretRect(for position: UITextPosition) -> CGRect { .zero }
|
||||
override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { [] }
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) { scheduleTextChange() }
|
||||
|
||||
override func insertText(_ text: String) {
|
||||
guard acceptingInput else { return }
|
||||
if (text == "\n" || text == "\r") && markedTextRange == nil {
|
||||
_ = textFieldShouldReturn(self)
|
||||
return
|
||||
}
|
||||
super.insertText(text)
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func replace(_ range: UITextRange, withText text: String) {
|
||||
guard acceptingInput else { return }
|
||||
super.replace(range, withText: text)
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
|
||||
guard acceptingInput else { return }
|
||||
super.setMarkedText(markedText, selectedRange: selectedRange)
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func unmarkText() {
|
||||
super.unmarkText()
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func deleteBackward() {
|
||||
guard acceptingInput else { return }
|
||||
if !hasText && markedTextRange == nil {
|
||||
// Flush any pending deletion before deleting beyond local context.
|
||||
if synchronizeText() { key?(0x08) }
|
||||
} else {
|
||||
super.deleteBackward()
|
||||
scheduleTextChange()
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
guard acceptingInput, markedTextRange == nil else { return false }
|
||||
if synchronizeText() {
|
||||
key?(0x0d)
|
||||
resetContext()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@objc private func scheduleTextChange() {
|
||||
guard acceptingInput, !resetting, !changeScheduled else { return }
|
||||
changeScheduled = true
|
||||
let currentGeneration = generation
|
||||
// UIKit may replace text and clear markedTextRange in separate calls.
|
||||
// Wait one run-loop turn so only the completed IME transaction is sent.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.generation == currentGeneration else { return }
|
||||
self.changeScheduled = false
|
||||
_ = self.synchronizeText()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func synchronizeText() -> Bool {
|
||||
guard acceptingInput, !resetting, markedTextRange == nil else { return false }
|
||||
let current = text ?? ""
|
||||
guard current != sentText else { return true }
|
||||
let prefixCount = zip(sentText, current).prefix(while: { $0 == $1 }).count
|
||||
let deleteCount = sentText.count - prefixCount
|
||||
let insertion = String(current.dropFirst(prefixCount))
|
||||
guard applyEdit?(deleteCount, insertion) == true else {
|
||||
// Do not keep invisible rejected input to replay on a later key.
|
||||
resetContext()
|
||||
return false
|
||||
}
|
||||
sentText = current
|
||||
return true
|
||||
}
|
||||
|
||||
func finishInputContext() {
|
||||
guard acceptingInput else { return }
|
||||
_ = synchronizeText()
|
||||
resetContext()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
acceptingInput = false
|
||||
applyEdit = nil
|
||||
key = nil
|
||||
resetContext()
|
||||
}
|
||||
|
||||
private func resetContext() {
|
||||
generation &+= 1
|
||||
changeScheduled = false
|
||||
resetting = true
|
||||
super.unmarkText()
|
||||
text = ""
|
||||
sentText = ""
|
||||
resetting = false
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
@Published var selectedDisplay = 0
|
||||
@Published var audioEnabled = true
|
||||
@Published private(set) var clipboardStatus = ""
|
||||
@Published private(set) var keyboardInputStatus = ""
|
||||
@Published private(set) var supportsTextInput = false
|
||||
@Published private(set) var controlStatus = ""
|
||||
@Published private(set) var transferStatus = ""
|
||||
@Published private(set) var transferProgress = 0.0
|
||||
@@ -735,6 +737,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
remoteVersionCheckAttempted = false
|
||||
remoteAppVersion = ""
|
||||
remotePlatform = .unknown
|
||||
supportsTextInput = false
|
||||
keyboardInputStatus = ""
|
||||
remoteHostInfoReceived = false
|
||||
remoteUpdateMessage = nil
|
||||
}
|
||||
@@ -1092,6 +1096,34 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
transferProgress = 0
|
||||
}
|
||||
|
||||
/// Validate the full edit before deleting anything at the remote caret.
|
||||
/// Corrections and dictation revisions share the ordered keyboard stream.
|
||||
func sendSystemTextEdit(deleting count: Int, inserting text: String) -> Bool {
|
||||
guard hasNetworkConsent, isConnected else { return false }
|
||||
guard (0...16 * 1024).contains(count), text.utf8.count <= 16 * 1024, !text.unicodeScalars.contains(where: { $0.value == 0 }) else {
|
||||
keyboardInputStatus = "单次输入最多 16 KiB,且不能包含空字符"
|
||||
return false
|
||||
}
|
||||
let isASCII = text.unicodeScalars.allSatisfy({
|
||||
(0x20...0x7e).contains($0.value) || [9, 10, 13].contains($0.value)
|
||||
})
|
||||
guard supportsTextInput || isASCII else {
|
||||
keyboardInputStatus = "被控端不支持直接输入中文,请更新 macOS 被控端;也可先复制文字,通过远控菜单的剪贴板发送后粘贴"
|
||||
return false
|
||||
}
|
||||
for _ in 0..<count { sendKeyStroke(0x08) }
|
||||
if !text.isEmpty {
|
||||
if supportsTextInput {
|
||||
bridge.sendCommittedText(text)
|
||||
} else {
|
||||
bridge.sendText(text.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n"))
|
||||
}
|
||||
}
|
||||
keyboardInputStatus = ""
|
||||
return true
|
||||
}
|
||||
|
||||
func sendKeyStroke(_ keyCode: UInt) {
|
||||
bridge.sendWindowsKeyCode(keyCode, isDown: true)
|
||||
bridge.sendWindowsKeyCode(keyCode, isDown: false)
|
||||
@@ -1287,7 +1319,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
platform: String,
|
||||
displayNames remoteDisplayNames: [String],
|
||||
displaySizes remoteDisplaySizes: [NSValue],
|
||||
supportsVideoSettings: Bool) {
|
||||
supportsVideoSettings: Bool,
|
||||
supportsTextInput: Bool) {
|
||||
self.supportsTextInput = supportsTextInput
|
||||
let previouslySupported = videoSettings.supported
|
||||
videoSettings.setSupported(supportsVideoSettings)
|
||||
if !supportsVideoSettings {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+69
-1
@@ -203,6 +203,74 @@ the available space. Settings and reading pages are limited to 720 points.
|
||||
Resizing preserves the connection fields and active session. iPhone retains its
|
||||
portrait home screen and two-column recent-connection grid.
|
||||
|
||||
## System keyboard and remote text
|
||||
|
||||
The session keyboard button opens the iPhone/iPad's selected system keyboard.
|
||||
Chinese candidates, nine-key layouts, handwriting, emoji and dictation use the
|
||||
installed keyboard's own UI and availability. Confirmed text is sent immediately
|
||||
without a visible local draft or a separate send action. Pinyin preedit stays in
|
||||
the system IME until composition is confirmed. Return presses remote Enter;
|
||||
Backspace edits an active composition locally, otherwise it deletes remotely.
|
||||
A hidden native text field retains the current input context so autocorrection,
|
||||
candidate replacement and continuous dictation can revise previously sent text.
|
||||
Only the changed suffix is replaced, using Backspace events followed by text on
|
||||
the same ordered keyboard stream. Remote clicks reset this context before moving
|
||||
the insertion point, and Return starts a new context. Text edited directly on
|
||||
the host is not mirrored back; automatic corrections assume the remote caret
|
||||
still follows the text entered from the phone.
|
||||
|
||||
Only the **电脑键盘** switch and keyboard hide button are visible in the 44-point
|
||||
native input row, anchored to `UIKeyboardLayoutGuide`. Its background continues
|
||||
behind the system keyboard; no input accessory slot is reserved. The IME's
|
||||
candidate bar remains native. Chinese nine-key Pinyin reserves its own
|
||||
spelling-choice row above the candidates (for example `m`, `n`, `o`); this may
|
||||
be empty before typing and is not an app spacer. An input error expands the
|
||||
row only while its message is visible. Computer keys such as Escape, Tab,
|
||||
arrows and modifiers are available on the computer keyboard.
|
||||
Switching between the system and computer keyboards replaces the panel in place
|
||||
in both directions. Opening the system keyboard after it has been hidden keeps
|
||||
the normal iOS presentation animation.
|
||||
|
||||
The computer keyboard has two pages: **字母数字** for letters and numbers,
|
||||
and **符号功能** for punctuation, F1–F12 and navigation. Keys fill each row
|
||||
with consistent 5-point horizontal and 6-point vertical gaps; there are no
|
||||
fixed keycap size limits or extra spacing around individual rows. The second
|
||||
page uses two function-key rows in portrait and one in landscape.
|
||||
The two bottom rows keep their height and position when changing pages.
|
||||
Shift and Backspace also keep the same width at the ends of the penultimate
|
||||
row; its middle keys fill the remaining width. Ctrl, Win, Alt, Space and Return
|
||||
share the last row. Only the upper rows redistribute the remaining height.
|
||||
Computer-key taps, page changes and keyboard controls use a light impact haptic
|
||||
at a fixed intensity of 1.0 alongside the existing pressed-key highlight.
|
||||
The public feedback API lets the system decide whether haptics are enabled;
|
||||
iOS does not expose the native keyboard's intensity for custom controls to read.
|
||||
One feedback generator is reused
|
||||
while the keyboard is visible and released when it closes. Haptics require
|
||||
supported hardware; the simulator can only verify the feedback trigger.
|
||||
Latched modifiers survive page changes and are released when the
|
||||
keyboard closes or switches back to the system keyboard. The panel reuses the
|
||||
last measured native keyboard height, including the 44-point control row, for
|
||||
the current window width and orientation. Before landscape has been measured,
|
||||
the iPhone fallback uses a 161-point compact keybed plus the actual bottom safe
|
||||
area and the 44-point control row, avoiding the oversized first rotation.
|
||||
Measured native heights take precedence over fallback sizes. Horizontal layouts
|
||||
put the page selectors in the control row to preserve key height. The bottom
|
||||
safe area stays clear. Both keyboards stay docked to the bottom of the screen;
|
||||
the computer keyboard has no drag handle or repositioning gesture.
|
||||
Closing the keyboard, switching keyboards, disconnecting or entering the
|
||||
background discards unfinished composition and pending callbacks. Already sent
|
||||
text remains on the remote computer.
|
||||
|
||||
Direct UTF-8 text requires a host advertising `supports_text_input`. The macOS
|
||||
host implements this with native Unicode keyboard events and does not modify
|
||||
the clipboard. Each replacement is validated before remote Backspace events are
|
||||
sent; text is limited to 16 KiB and deletions to 16,384 characters per update.
|
||||
Hosts without this capability retain ASCII key input. Unsupported input is
|
||||
rejected with an explanation and is not queued for later replay. For Windows,
|
||||
Linux or older macOS hosts, copy text from another app and send it with
|
||||
**剪贴板** in the remote session menu, then use the computer keyboard's paste
|
||||
shortcut.
|
||||
|
||||
## App updates
|
||||
|
||||
After network consent, the app checks the public version manifest once on each
|
||||
@@ -366,7 +434,7 @@ Old previews saved without this choice are removed on upgrade.
|
||||
The floating control menu includes **虚拟鼠标** (virtual mouse), a 140 × 48 pt
|
||||
horizontal left-button / wheel / right-button bar attached to the status orb.
|
||||
Drag the orb to reposition it, or use **收起鼠标** to hide it. The bar overlays
|
||||
the video without resizing it and avoids the floating keyboard. Hold either mouse
|
||||
the video without resizing it and avoids the computer keyboard. Hold either mouse
|
||||
button and drag the same finger, including beyond the key bounds, to drag at the
|
||||
remote cursor; lifting the finger releases that button. Moving another finger on
|
||||
the video while holding a button also works. The flat split design has unlabelled
|
||||
|
||||
@@ -31,6 +31,7 @@ enum ControlType {
|
||||
privacy_status = 10,
|
||||
video_settings = 11,
|
||||
video_settings_status = 12,
|
||||
text_input = 13,
|
||||
};
|
||||
|
||||
enum MouseFlag {
|
||||
@@ -137,6 +138,7 @@ struct HostInfo {
|
||||
// Optional capability; absent in legacy desktop and web host information.
|
||||
bool supports_privacy_screen;
|
||||
bool supports_video_settings;
|
||||
bool supports_text_input;
|
||||
bool supports_deskhub_preview;
|
||||
bool supports_deskhub_resolution;
|
||||
bool supports_deskhub_fps15;
|
||||
@@ -183,6 +185,8 @@ inline bool ValidVideoSettings(const VideoSettings& settings) {
|
||||
settings.preference >= 0 && settings.preference <= 2;
|
||||
}
|
||||
|
||||
inline constexpr std::size_t kMaxTextInputBytes = 16 * 1024;
|
||||
|
||||
struct RemoteAction {
|
||||
ControlType type = ControlType::invalid;
|
||||
union {
|
||||
@@ -200,6 +204,9 @@ struct RemoteAction {
|
||||
VideoSettings vs;
|
||||
};
|
||||
|
||||
// Committed UTF-8 text. Kept outside the POD union for automatic ownership.
|
||||
std::string text;
|
||||
|
||||
std::string to_json() const;
|
||||
bool from_json(const std::string& json_string);
|
||||
|
||||
@@ -208,7 +215,7 @@ struct RemoteAction {
|
||||
};
|
||||
|
||||
// Releases the dynamically allocated display arrays held by host information.
|
||||
// Other RemoteAction variants do not own memory and are left unchanged.
|
||||
// Other RemoteAction variants require no explicit cleanup and are left unchanged.
|
||||
void FreeRemoteAction(RemoteAction& action);
|
||||
|
||||
// One display as advertised in host information; bounds are virtual-desktop
|
||||
|
||||
@@ -26,6 +26,7 @@ void ResetHostInfo(HostInfo& info) {
|
||||
info.bottom = nullptr;
|
||||
info.supports_privacy_screen = false;
|
||||
info.supports_video_settings = false;
|
||||
info.supports_text_input = false;
|
||||
info.supports_deskhub_preview = false;
|
||||
info.supports_deskhub_resolution = false;
|
||||
info.supports_deskhub_fps15 = false;
|
||||
@@ -136,6 +137,11 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
{"s", action.m.s},
|
||||
{"flag", action.m.flag}};
|
||||
break;
|
||||
case ControlType::text_input:
|
||||
if (action.text.empty() || action.text.size() > kMaxTextInputBytes ||
|
||||
action.text.find('\0') != std::string::npos) return {};
|
||||
object["text"] = action.text;
|
||||
break;
|
||||
case ControlType::keyboard:
|
||||
object["keyboard"] = {{"key_value", action.k.key_value},
|
||||
{"scan_code", action.k.scan_code},
|
||||
@@ -226,6 +232,7 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
{"displays", displays},
|
||||
{"supports_privacy_screen", action.i.supports_privacy_screen},
|
||||
{"supports_video_settings", action.i.supports_video_settings},
|
||||
{"supports_text_input", action.i.supports_text_input},
|
||||
{"supports_deskhub_preview", action.i.supports_deskhub_preview},
|
||||
{"supports_deskhub_resolution", action.i.supports_deskhub_resolution},
|
||||
{"supports_deskhub_fps15", action.i.supports_deskhub_fps15},
|
||||
@@ -253,6 +260,11 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
output.m.flag = static_cast<MouseFlag>(
|
||||
object.at("mouse").at("flag").get<int>());
|
||||
break;
|
||||
case ControlType::text_input:
|
||||
output.text = object.at("text").get<std::string>();
|
||||
if (output.text.empty() || output.text.size() > kMaxTextInputBytes ||
|
||||
output.text.find('\0') != std::string::npos) return false;
|
||||
break;
|
||||
case ControlType::keyboard:
|
||||
output.k.key_value =
|
||||
object.at("keyboard").at("key_value").get<std::size_t>();
|
||||
@@ -420,6 +432,8 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
output.i.supports_privacy_screen =
|
||||
privacy_support != host_info_object.end() &&
|
||||
privacy_support->is_boolean() && privacy_support->get<bool>();
|
||||
output.i.supports_text_input =
|
||||
host_info_object.value("supports_text_input", false);
|
||||
output.i.supports_video_settings =
|
||||
host_info_object.value("supports_video_settings", false);
|
||||
output.i.supports_deskhub_preview =
|
||||
|
||||
Reference in New Issue
Block a user