[feat] add iOS system keyboard input and improve computer keyboard

This commit is contained in:
dijunkun
2026-10-18 20:15:45 +08:00
parent 02c3952be0
commit 42856be8b8
18 changed files with 1111 additions and 456 deletions
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
+14
View File
@@ -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 =