From 42856be8b83c78473b55e5845eede039b819c269 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 18 Oct 2026 20:15:45 +0800 Subject: [PATCH] [feat] add iOS system keyboard input and improve computer keyboard --- .../src/device_controller/device_controller.h | 4 +- .../devices/session_device_manager.cpp | 8 + .../features/devices/session_device_manager.h | 2 + .../features/input/keyboard_controller.cpp | 9 + .../gui/features/input/keyboard_controller.h | 1 + .../src/gui/runtime/peer_data_callbacks.cpp | 7 + .../src/gui/runtime/peer_event_handler.cpp | 2 + .../input/keyboard/keyboard_capturer.cpp | 44 + .../macos/input/keyboard/keyboard_capturer.h | 4 +- .../CrossDeskMobile.xcodeproj/project.pbxproj | 4 + .../Bridge/CrossDeskRTCBridge.h | 4 +- .../Bridge/CrossDeskRTCBridge.mm | 24 +- .../Input/RemoteSystemKeyboard.swift | 540 ++++++++++++ .../CrossDeskMobile/RemoteSessionModel.swift | 36 +- .../CrossDeskMobile/RemoteSessionView.swift | 785 ++++++++---------- apps/ios/README.md | 70 +- libs/wire/include/remote_action.h | 9 +- libs/wire/src/remote_action.cpp | 14 + 18 files changed, 1111 insertions(+), 456 deletions(-) create mode 100644 apps/ios/CrossDeskMobile/Input/RemoteSystemKeyboard.swift diff --git a/apps/desktop/src/device_controller/device_controller.h b/apps/desktop/src/device_controller/device_controller.h index bd8ef43b..4c6349af 100644 --- a/apps/desktop/src/device_controller/device_controller.h +++ b/apps/desktop/src/device_controller/device_controller.h @@ -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; diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.cpp b/apps/desktop/src/gui/features/devices/session_device_manager.cpp index 0f25ce18..ab397060 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -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) { diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.h b/apps/desktop/src/gui/features/devices/session_device_manager.h index 9597a68b..a05620da 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.h +++ b/apps/desktop/src/gui/features/devices/session_device_manager.h @@ -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); diff --git a/apps/desktop/src/gui/features/input/keyboard_controller.cpp b/apps/desktop/src/gui/features/input/keyboard_controller.cpp index 152c1266..917f73b4 100644 --- a/apps/desktop/src/gui/features/input/keyboard_controller.cpp +++ b/apps/desktop/src/gui/features/input/keyboard_controller.cpp @@ -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_); diff --git a/apps/desktop/src/gui/features/input/keyboard_controller.h b/apps/desktop/src/gui/features/input/keyboard_controller.h index 09b0108e..20ba2a7d 100644 --- a/apps/desktop/src/gui/features/input/keyboard_controller.h +++ b/apps/desktop/src/gui/features/input/keyboard_controller.h @@ -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, diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 0b42db98..7db52ed3 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -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 || diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 0d2c9674..02d33cc9 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -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; diff --git a/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp b/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp index f1a05fc6..2d49ec34 100644 --- a/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp +++ b/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp @@ -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(text.data()), + text.size(), kCFStringEncodingUTF8, false); + if (!string) return -1; + const CFIndex length = CFStringGetLength(string); + std::vector 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 diff --git a/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.h b/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.h index 896299d3..f506bf77 100644 --- a/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.h +++ b/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.h @@ -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); diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index 59c154cf..097511fd 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -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 = ""; }; 20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = ""; }; 20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = ""; }; 200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = ""; }; @@ -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 */, diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index ae696589..475c6b30 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -89,7 +89,8 @@ typedef struct { platform:(NSString *)platform displayNames:(NSArray *)displayNames displaySizes:(NSArray *)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; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 9b188cc9..379f086e 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -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(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(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 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; diff --git a/apps/ios/CrossDeskMobile/Input/RemoteSystemKeyboard.swift b/apps/ios/CrossDeskMobile/Input/RemoteSystemKeyboard.swift new file mode 100644 index 00000000..2b5d1459 --- /dev/null +++ b/apps/ios/CrossDeskMobile/Input/RemoteSystemKeyboard.swift @@ -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 + } +} diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index 1692d691..ec1cad52 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -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.. Color { + Color(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + }) + } } private enum RemoteKeyboardMetrics { - static let rowHeight: CGFloat = 31 - static let keySpacing: CGFloat = 3 - static let rowSpacing: CGFloat = 3 - static let cornerRadius: CGFloat = 5 - static let panelPadding: CGFloat = 5 - static let dragHandleHeight: CGFloat = 10 + static let keySpacing: CGFloat = 5 + static let rowSpacing: CGFloat = 6 + static let cornerRadius: CGFloat = 7 + static let panelPadding: CGFloat = 6 } private enum RemoteKeyboardMode: String { @@ -34,17 +43,19 @@ struct RemoteSessionView: View { @Environment(\.scenePhase) private var scenePhase @State private var mouseInput = RemoteMouseInput() @State private var virtualMouseVisible = false + @StateObject private var systemKeyboardContext = RemoteKeyboardInputContext() + @StateObject private var keyboardGeometry = RemoteKeyboardGeometry() @State private var keyboardInputVisible = false @State private var keyboardMode: RemoteKeyboardMode = .system + @State private var animateSystemKeyboardPresentation = true + @State private var waitingForSystemKeyboard = false @State private var viewport = RemoteViewportState() @State private var showingFileImporter = false @State private var disconnectConfirmationVisible = false @State private var cursorPosition: CGPoint? @State private var statusMenuVisible = false @State private var statusOrbCenter: CGPoint? - @State private var keyboardPanelCenter: CGPoint? @GestureState private var statusOrbDragTranslation = CGSize.zero - @GestureState private var keyboardDragTranslation = CGSize.zero var body: some View { ZStack { @@ -113,6 +124,7 @@ struct RemoteSessionView: View { mouseInput.setGestureLeft(false) }, rightClick: { x, y in + systemKeyboardContext.remoteFocusWillChange() session.bridge.sendPointer(x: x, y: y, action: .rightDown) session.bridge.sendPointer(x: x, y: y, action: .rightUp) }, @@ -149,7 +161,20 @@ struct RemoteSessionView: View { } if keyboardInputVisible { - draggableKeyboard + ZStack { + if keyboardMode == .system { + RemoteSystemKeyboard(session: session, + inputContext: systemKeyboardContext, + geometry: keyboardGeometry, + animatePresentation: animateSystemKeyboardPresentation, + didPresent: { waitingForSystemKeyboard = false }, + switchKeyboard: { keyboardMode = .computer }, + dismiss: { keyboardInputVisible = false }) + } + if keyboardMode == .computer || waitingForSystemKeyboard { + dockedKeyboard + } + } .zIndex(20) } @@ -172,6 +197,7 @@ struct RemoteSessionView: View { .onAppear { mouseInput.send = { x, y, flag, wheel in cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) + if flag == 1 || flag == 3 { systemKeyboardContext.remoteFocusWillChange() } switch flag { case 0: session.bridge.sendPointer(x: x, y: y, action: .move) case 1: session.bridge.sendPointer(x: x, y: y, action: .leftDown) @@ -195,9 +221,9 @@ struct RemoteSessionView: View { cursorPosition = nil statusMenuVisible = false statusOrbCenter = nil - keyboardPanelCenter = nil } .onChange(of: session.selectedDisplay) { _ in + systemKeyboardContext.remoteFocusWillChange() mouseInput.releaseAll() mouseInput.position(x: 0.5, y: 0.5) cursorPosition = nil @@ -212,9 +238,19 @@ struct RemoteSessionView: View { } .onChange(of: scenePhase) { phase in if phase != .active { mouseInput.releaseAll() } + if phase == .background { keyboardInputVisible = false } + } + .onChange(of: keyboardInputVisible) { visible in + if !visible { + animateSystemKeyboardPresentation = true + waitingForSystemKeyboard = false + } } .onChange(of: session.isConnected) { connected in - if !connected { mouseInput.releaseAll() } + if !connected { + mouseInput.releaseAll() + keyboardInputVisible = false + } } .onChange(of: session.remoteCursorPositionRevision) { _ in guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return } @@ -351,6 +387,7 @@ struct RemoteSessionView: View { }, chooseFile: { statusMenuVisible = false + keyboardInputVisible = false showingFileImporter = true }, close: { @@ -427,10 +464,9 @@ struct RemoteSessionView: View { } } x = min(max(x, halfWidth + 8), max(halfWidth + 8, size.width - halfWidth - 8)) - if keyboardInputVisible { + if keyboardInputVisible && keyboardMode == .computer { let keyboardSize = keyboardPanelSize(in: size) - let resting = constrainedKeyboardCenter(keyboardPanelCenter ?? defaultKeyboardCenter(in: size, panelSize: keyboardSize), in: size, panelSize: keyboardSize) - let center = constrainedKeyboardCenter(CGPoint(x: resting.x + keyboardDragTranslation.width, y: resting.y + keyboardDragTranslation.height), in: size, panelSize: keyboardSize) + let center = keyboardCenter(in: size, panelSize: keyboardSize) let keyboardRect = CGRect(x: center.x - keyboardSize.width / 2, y: center.y - keyboardSize.height / 2, width: keyboardSize.width, height: keyboardSize.height) if keyboardRect.intersects(CGRect(x: x - halfWidth, y: y - halfHeight, width: 140, height: 48)) { y = keyboardRect.minY - 6 - halfHeight @@ -440,129 +476,53 @@ struct RemoteSessionView: View { return CGPoint(x: x, y: min(max(y, halfHeight + 8), max(halfHeight + 8, size.height - halfHeight - 8))) } - private var draggableKeyboard: some View { + private var dockedKeyboard: some View { GeometryReader { proxy in - let containerSize = proxy.size - let panelSize = keyboardPanelSize(in: containerSize) - let restingCenter = constrainedKeyboardCenter( - keyboardPanelCenter ?? defaultKeyboardCenter( - in: containerSize, - panelSize: panelSize - ), - in: containerSize, - panelSize: panelSize - ) - let visibleCenter = constrainedKeyboardCenter( - CGPoint( - x: restingCenter.x + keyboardDragTranslation.width, - y: restingCenter.y + keyboardDragTranslation.height - ), - in: containerSize, - panelSize: panelSize - ) - - remoteKeyboardPanel(containerSize: containerSize, - panelSize: panelSize) + let containerSize = CGSize(width: proxy.size.width, + height: proxy.size.height + proxy.safeAreaInsets.bottom) + let windowSize = CGSize( + width: proxy.size.width + proxy.safeAreaInsets.leading + proxy.safeAreaInsets.trailing, + height: containerSize.height + proxy.safeAreaInsets.top) + let panelSize = keyboardPanelSize(in: containerSize, + bottomInset: proxy.safeAreaInsets.bottom, + windowSize: windowSize) + remoteKeyboardPanel(bottomInset: proxy.safeAreaInsets.bottom) .frame(width: panelSize.width, height: panelSize.height) - .position(restingCenter) - .offset(x: visibleCenter.x - restingCenter.x, - y: visibleCenter.y - restingCenter.y) + .position(keyboardCenter(in: containerSize, panelSize: panelSize)) .transaction { transaction in transaction.animation = nil } } + .ignoresSafeArea(.keyboard) } - private func remoteKeyboardPanel(containerSize: CGSize, - panelSize: CGSize) -> some View { - VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { - Capsule() - .fill(Color.black.opacity(0.28)) - .frame(width: 38, height: 4) - .frame(maxWidth: .infinity) - .frame(height: RemoteKeyboardMetrics.dragHandleHeight) - .contentShape(Rectangle()) - .gesture(keyboardDragGesture(containerSize: containerSize, - panelSize: panelSize)) - .accessibilityLabel("拖动键盘") - - Group { - if keyboardMode == .system { - RemoteSystemKeyboard( - session: session, - switchKeyboard: { keyboardMode = .computer }, - dismiss: { keyboardInputVisible = false } - ) - } else { - RemoteComputerKeyboard( - session: session, - switchKeyboard: { keyboardMode = .system }, - dismiss: { keyboardInputVisible = false } - ) - } - } - } - .padding(RemoteKeyboardMetrics.panelPadding) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(RemoteKeyboardPalette.background, - in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .shadow(color: .black.opacity(0.2), radius: 10, y: 4) + private func remoteKeyboardPanel(bottomInset: CGFloat) -> some View { + RemoteComputerKeyboard( + session: session, + switchKeyboard: { + animateSystemKeyboardPresentation = false + waitingForSystemKeyboard = true + keyboardMode = .system + }, + dismiss: { keyboardInputVisible = false } + ) + .padding(.bottom, bottomInset) + .background(RemoteKeyboardPalette.background) } - private func keyboardPanelSize(in containerSize: CGSize) -> CGSize { - let rowCount: CGFloat = keyboardMode == .system ? 4 : 6 - let keyboardHeight = rowCount * RemoteKeyboardMetrics.rowHeight + - (rowCount - 1) * RemoteKeyboardMetrics.rowSpacing - let height = keyboardHeight + - RemoteKeyboardMetrics.dragHandleHeight + - RemoteKeyboardMetrics.rowSpacing + - RemoteKeyboardMetrics.panelPadding * 2 - return CGSize(width: min(780, max(0, containerSize.width - 12)), - height: height) + private func keyboardPanelSize(in containerSize: CGSize, + bottomInset: CGFloat = 0, + windowSize: CGSize? = nil) -> CGSize { + CGSize(width: min(780, max(0, containerSize.width)), + height: min(containerSize.height, + keyboardGeometry.panelHeight(in: windowSize ?? containerSize, + bottomInset: bottomInset))) } - private func defaultKeyboardCenter(in containerSize: CGSize, - panelSize: CGSize) -> CGPoint { + private func keyboardCenter(in containerSize: CGSize, + panelSize: CGSize) -> CGPoint { CGPoint(x: containerSize.width / 2, - y: containerSize.height - panelSize.height / 2 - 6) - } - - private func constrainedKeyboardCenter(_ point: CGPoint, - in containerSize: CGSize, - panelSize: CGSize) -> CGPoint { - let margin: CGFloat = 6 - let minimumX = panelSize.width / 2 + margin - let maximumX = max(minimumX, - containerSize.width - panelSize.width / 2 - margin) - let minimumY = panelSize.height / 2 + margin - let maximumY = max(minimumY, - containerSize.height - panelSize.height / 2 - margin) - return CGPoint(x: min(max(point.x, minimumX), maximumX), - y: min(max(point.y, minimumY), maximumY)) - } - - private func keyboardDragGesture(containerSize: CGSize, - panelSize: CGSize) -> some Gesture { - DragGesture(minimumDistance: 1, coordinateSpace: .global) - .updating($keyboardDragTranslation) { value, state, _ in - state = value.translation - } - .onEnded { value in - let restingCenter = constrainedKeyboardCenter( - keyboardPanelCenter ?? defaultKeyboardCenter( - in: containerSize, - panelSize: panelSize - ), - in: containerSize, - panelSize: panelSize - ) - keyboardPanelCenter = constrainedKeyboardCenter( - CGPoint(x: restingCenter.x + value.translation.width, - y: restingCenter.y + value.translation.height), - in: containerSize, - panelSize: panelSize - ) - } + y: containerSize.height - panelSize.height / 2) } private func boundedOrbCenter(in size: CGSize) -> CGPoint { @@ -595,32 +555,16 @@ struct RemoteSessionView: View { } -private enum RemoteSystemKeyboardPage { - case letters - case numbers - case symbols -} - -private enum RemoteSystemKeyAction { - case text(String) - case shift - case backspace - case returnKey - case page(RemoteSystemKeyboardPage) - case switchKeyboard - case dismiss -} - -private enum RemoteSystemKeyStyle { +private enum RemoteKeyboardKeyStyle { case character case special - case accent } private struct RemoteKeyboardKeyCap: View { + @Environment(\.verticalSizeClass) private var verticalSizeClass let title: String? let symbol: String? - let style: RemoteSystemKeyStyle + let style: RemoteKeyboardKeyStyle let active: Bool let action: () -> Void @@ -629,261 +573,93 @@ private struct RemoteKeyboardKeyCap: View { Group { if let symbol { Image(systemName: symbol) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 17, weight: .regular)) + } else if let title, title.contains("\n") { + let legends = title.components(separatedBy: "\n") + if verticalSizeClass == .compact { + HStack(spacing: 3) { + Text(legends[1]).font(.system(size: 17)) + Text(legends[0]).font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } else { + VStack(spacing: 0) { + Text(legends[0]) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Text(legends[1]) + .font(.system(size: 18)) + } + } } else { Text(title ?? "") .font(.system(size: labelFontSize, - weight: .medium, - design: .rounded)) + weight: .regular)) .multilineTextAlignment(.center) - .lineLimit(2) + .lineLimit(1) .minimumScaleFactor(0.62) } } - .foregroundStyle(usesAccentColor - ? Color.white : RemoteKeyboardPalette.keyText) + .foregroundStyle(active ? Color.black : RemoteKeyboardPalette.keyText) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(backgroundColor, - in: RoundedRectangle( - cornerRadius: RemoteKeyboardMetrics.cornerRadius, - style: .continuous - )) - .shadow(color: .black.opacity(0.14), radius: 0.4, y: 0.8) + .contentShape(Rectangle()) } - .buttonStyle(RemoteKeyboardPressStyle()) + .buttonStyle(RemoteKeyboardPressStyle(backgroundColor: backgroundColor)) + .accessibilityAddTraits(active ? .isSelected : []) } private var labelFontSize: CGFloat { guard let title else { return 14 } - if title.contains("\n") { return 9 } switch title.count { - case 0...1: return 14 - case 2...3: return 11.5 - default: return 10 + case 0...1: return 20 + case 2...3: return 14 + default: return 14 } } - private var usesAccentColor: Bool { - if active { return true } - if case .accent = style { return true } - return false - } - private var backgroundColor: Color { - if usesAccentColor { return RemoteKeyboardPalette.accentKey } + // Match the system keyboard's latched Shift appearance. + if active { return .white } switch style { case .character: return RemoteKeyboardPalette.characterKey case .special: return RemoteKeyboardPalette.specialKey - case .accent: return RemoteKeyboardPalette.accentKey } } } private struct RemoteKeyboardPressStyle: ButtonStyle { + let backgroundColor: Color + func makeBody(configuration: Configuration) -> some View { configuration.label - .brightness(configuration.isPressed ? -0.08 : 0) - .scaleEffect(configuration.isPressed ? 0.97 : 1) + .background { + RoundedRectangle(cornerRadius: RemoteKeyboardMetrics.cornerRadius, + style: .continuous) + .fill(configuration.isPressed + ? RemoteKeyboardPalette.pressedKey : backgroundColor) + .shadow(color: RemoteKeyboardPalette.keyShadow, radius: 0, y: 1) + } } } -private struct RemoteSystemKey { - let title: String? - let symbol: String? - let width: CGFloat - let style: RemoteSystemKeyStyle - let action: RemoteSystemKeyAction +private final class RemoteKeyboardFeedback: ObservableObject { + private var generator: UIImpactFeedbackGenerator? - init(_ title: String, - width: CGFloat = 1, - style: RemoteSystemKeyStyle = .character, - action: RemoteSystemKeyAction) { - self.title = title - self.symbol = nil - self.width = width - self.style = style - self.action = action + func prepare() { + if generator == nil { generator = UIImpactFeedbackGenerator(style: .light) } + generator?.prepare() } - init(symbol: String, - width: CGFloat = 1, - style: RemoteSystemKeyStyle = .special, - action: RemoteSystemKeyAction) { - self.title = nil - self.symbol = symbol - self.width = width - self.style = style - self.action = action + func keyPressed() { + let generator = generator ?? UIImpactFeedbackGenerator(style: .light) + self.generator = generator + generator.impactOccurred(intensity: 1.0) + // Keep rapid typing responsive without repeatedly allocating generators. + generator.prepare() } - var isShift: Bool { - if case .shift = action { return true } - return false - } - - var isLetter: Bool { - guard case let .text(value) = action else { return false } - return value.rangeOfCharacter(from: .letters) != nil - } -} - -private struct RemoteSystemKeyboard: View { - @ObservedObject var session: RemoteSessionModel - let switchKeyboard: () -> Void - let dismiss: () -> Void - - @State private var page: RemoteSystemKeyboardPage = .letters - @State private var shiftEnabled = false - - var body: some View { - VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - RemoteSystemKeyboardRow( - keys: row, - shiftEnabled: shiftEnabled, - action: handleKey - ) - } - } - } - - private var rows: [[RemoteSystemKey]] { - switch page { - case .letters: - return letterRows - case .numbers: - return numberRows - case .symbols: - return symbolRows - } - } - - private var letterRows: [[RemoteSystemKey]] { - [ - textKeys("qwertyuiop"), - textKeys("asdfghjkl"), - [RemoteSystemKey(symbol: "shift", - width: 1.55, - action: .shift)] + - textKeys("zxcvbnm") + - [RemoteSystemKey(symbol: "delete.left", - width: 1.55, - action: .backspace)], - bottomRow(pageTitle: "123", targetPage: .numbers) - ] - } - - private var numberRows: [[RemoteSystemKey]] { - [ - valueKeys(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]), - valueKeys(["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""]), - [RemoteSystemKey("#+=", width: 1.55, style: .special, - action: .page(.symbols))] + - valueKeys([".", ",", "?", "!", "'"]) + - [RemoteSystemKey(symbol: "delete.left", width: 1.55, - action: .backspace)], - bottomRow(pageTitle: "ABC", targetPage: .letters) - ] - } - - private var symbolRows: [[RemoteSystemKey]] { - [ - valueKeys(["[", "]", "{", "}", "#", "%", "^", "*", "+", "="]), - valueKeys(["_", "\\", "|", "~", "<", ">", "$", "&", "@", "`"]), - [RemoteSystemKey("123", width: 1.55, style: .special, - action: .page(.numbers))] + - valueKeys([".", ",", "?", "!", "'"]) + - [RemoteSystemKey(symbol: "delete.left", width: 1.55, - action: .backspace)], - bottomRow(pageTitle: "ABC", targetPage: .letters) - ] - } - - private func textKeys(_ letters: String) -> [RemoteSystemKey] { - letters.map { character in - let value = String(character) - return RemoteSystemKey(value, action: .text(value)) - } - } - - private func valueKeys(_ values: [String]) -> [RemoteSystemKey] { - values.map { RemoteSystemKey($0, action: .text($0)) } - } - - private func bottomRow(pageTitle: String, - targetPage: RemoteSystemKeyboardPage) - -> [RemoteSystemKey] { - [ - RemoteSystemKey(pageTitle, width: 1.45, style: .special, - action: .page(targetPage)), - RemoteSystemKey(symbol: "arrow.left.arrow.right", width: 1.2, - action: .switchKeyboard), - RemoteSystemKey("空格", width: 5, action: .text(" ")), - RemoteSystemKey("回车", width: 1.55, style: .accent, - action: .returnKey), - RemoteSystemKey(symbol: "keyboard.chevron.compact.down", width: 1.2, - action: .dismiss) - ] - } - - private func handleKey(_ key: RemoteSystemKey) { - switch key.action { - case let .text(value): - let isLetter = value.rangeOfCharacter(from: .letters) != nil - session.bridge.sendText(shiftEnabled && isLetter - ? value.uppercased() : value) - case .shift: - withTransaction(Transaction(animation: nil)) { - shiftEnabled.toggle() - } - case .backspace: - session.sendKeyStroke(0x08) - case .returnKey: - session.sendKeyStroke(0x0D) - case let .page(newPage): - page = newPage - case .switchKeyboard: - switchKeyboard() - case .dismiss: - dismiss() - } - } -} - -private struct RemoteSystemKeyboardRow: View { - let keys: [RemoteSystemKey] - let shiftEnabled: Bool - let action: (RemoteSystemKey) -> Void - - var body: some View { - GeometryReader { proxy in - let spacing = RemoteKeyboardMetrics.keySpacing - let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width } - let keySpace = max(0, proxy.size.width - - CGFloat(max(0, keys.count - 1)) * spacing) - - HStack(spacing: spacing) { - ForEach(Array(keys.enumerated()), id: \.offset) { _, key in - let isActive = key.isShift && shiftEnabled - let visibleTitle = shiftEnabled && key.isLetter - ? key.title?.uppercased() : key.title - let visibleSymbol = isActive && key.isShift - ? "shift.fill" : key.symbol - RemoteKeyboardKeyCap( - title: visibleTitle, - symbol: visibleSymbol, - style: key.style, - active: isActive - ) { - action(key) - } - .id("\(visibleTitle ?? visibleSymbol ?? "key")-\(isActive)") - .frame(width: keySpace * key.width / totalWidth) - .accessibilityLabel(visibleTitle ?? "键盘操作") - } - } - } - .frame(height: RemoteKeyboardMetrics.rowHeight) + func stop() { + generator = nil } } @@ -898,14 +674,14 @@ private struct RemoteComputerKey { let title: String? let symbol: String? let width: CGFloat - let style: RemoteSystemKeyStyle + let style: RemoteKeyboardKeyStyle let action: RemoteComputerKeyAction init(_ title: String, _ keyCode: UInt, width: CGFloat = 1, isModifier: Bool = false, - style: RemoteSystemKeyStyle? = nil) { + style: RemoteKeyboardKeyStyle? = nil) { self.title = title self.symbol = nil self.width = width @@ -915,7 +691,7 @@ private struct RemoteComputerKey { init(_ title: String, width: CGFloat = 1, - style: RemoteSystemKeyStyle = .special, + style: RemoteKeyboardKeyStyle = .special, action: RemoteComputerKeyAction) { self.title = title self.symbol = nil @@ -928,7 +704,7 @@ private struct RemoteComputerKey { _ keyCode: UInt, width: CGFloat = 1, isModifier: Bool = false, - style: RemoteSystemKeyStyle? = nil) { + style: RemoteKeyboardKeyStyle? = nil) { self.title = nil self.symbol = symbol self.width = width @@ -938,7 +714,7 @@ private struct RemoteComputerKey { init(symbol: String, width: CGFloat = 1, - style: RemoteSystemKeyStyle = .special, + style: RemoteKeyboardKeyStyle = .special, action: RemoteComputerKeyAction) { self.title = nil self.symbol = symbol @@ -947,94 +723,204 @@ private struct RemoteComputerKey { self.action = action } + var accessibilityLabel: String { + if let title { return title.replacingOccurrences(of: "\n", with: " ") } + switch symbol { + case "shift": return "Shift" + case "delete.left": return "退格" + default: return "键盘操作" + } + } + var modifierCode: UInt? { if case let .modifier(keyCode) = action { return keyCode } return nil } } +private enum RemoteComputerKeyboardPage: String, CaseIterable { + case input = "字母数字" + case controls = "符号功能" + + var rows: [[RemoteComputerKey]] { + let shift = RemoteComputerKey(symbol: "shift", 0x10, isModifier: true) + let backspace = RemoteComputerKey(symbol: "delete.left", 0x08, style: .special) + switch self { + case .input: + return [ + [.init("!\n1", 0x31), .init("@\n2", 0x32), .init("#\n3", 0x33), + .init("$\n4", 0x34), .init("%\n5", 0x35), .init("^\n6", 0x36), + .init("&\n7", 0x37), .init("*\n8", 0x38), .init("(\n9", 0x39), + .init(")\n0", 0x30)], + [.init("Q", 0x51), .init("W", 0x57), .init("E", 0x45), + .init("R", 0x52), .init("T", 0x54), .init("Y", 0x59), + .init("U", 0x55), .init("I", 0x49), .init("O", 0x4F), + .init("P", 0x50)], + [.init("A", 0x41), .init("S", 0x53), .init("D", 0x44), + .init("F", 0x46), .init("G", 0x47), .init("H", 0x48), + .init("J", 0x4A), .init("K", 0x4B), .init("L", 0x4C)], + [shift, .init("Z", 0x5A), .init("X", 0x58), .init("C", 0x43), + .init("V", 0x56), .init("B", 0x42), .init("N", 0x4E), + .init("M", 0x4D), backspace] + ] + case .controls: + return [ + [.init("~\n`", 0xC0), .init("_\n-", 0xBD), .init("+\n=", 0xBB), + .init("{\n[", 0xDB), .init("}\n]", 0xDD), .init("|\n\\", 0xDC), + .init(":\n;", 0xBA), .init("\"\n'", 0xDE), + .init("<\n,", 0xBC), .init(">\n.", 0xBE), .init("?\n/", 0xBF)], + [.init("F1", 0x70), .init("F2", 0x71), .init("F3", 0x72), + .init("F4", 0x73), .init("F5", 0x74), .init("F6", 0x75)], + [.init("F7", 0x76), .init("F8", 0x77), .init("F9", 0x78), + .init("F10", 0x79), .init("F11", 0x7A), .init("F12", 0x7B)], + [.init("Esc", 0x1B, style: .special), .init("Tab", 0x09, style: .special), + .init("Caps", 0x14), .init("Ins", 0x2D), .init("Del", 0x2E), + .init("Home", 0x24), .init("End", 0x23)], + [shift, .init("←", 0x25), .init("↑", 0x26), .init("↓", 0x28), + .init("→", 0x27), .init("PgUp", 0x21), .init("PgDn", 0x22), backspace] + ] + } + } + + func rows(compact: Bool) -> [[RemoteComputerKey]] { + let rows = self.rows + guard compact, self == .controls else { return rows } + // Landscape has room for one full function-key row, leaving the same + // five-row height budget as the input page (including common keys). + return [rows[0], rows[1] + rows[2], rows[3], rows[4]] + } +} + private struct RemoteComputerKeyboard: View { @ObservedObject var session: RemoteSessionModel + @Environment(\.verticalSizeClass) private var verticalSizeClass + @StateObject private var feedback = RemoteKeyboardFeedback() let switchKeyboard: () -> Void let dismiss: () -> Void @State private var activeModifiers: Set = [] + @State private var page: RemoteComputerKeyboardPage = .input - private static let rows: [[RemoteComputerKey]] = [ - [ - .init("Esc", 0x1B, width: 1.2), - .init("F1", 0x70), .init("F2", 0x71), .init("F3", 0x72), - .init("F4", 0x73), .init("F5", 0x74), .init("F6", 0x75), - .init("F7", 0x76), .init("F8", 0x77), .init("F9", 0x78), - .init("F10", 0x79), .init("F11", 0x7A), .init("F12", 0x7B), - .init("Del", 0x2E, width: 1.2) - ], - [ - .init("~\n`", 0xC0), - .init("!\n1", 0x31), .init("@\n2", 0x32), - .init("#\n3", 0x33), .init("$\n4", 0x34), - .init("%\n5", 0x35), .init("^\n6", 0x36), - .init("&\n7", 0x37), .init("*\n8", 0x38), - .init("(\n9", 0x39), .init(")\n0", 0x30), - .init("_\n-", 0xBD), .init("+\n=", 0xBB), - .init(symbol: "delete.left", 0x08, width: 1.65, - style: .special) - ], - [ - .init("Tab", 0x09, width: 1.45, style: .special), - .init("Q", 0x51), .init("W", 0x57), .init("E", 0x45), - .init("R", 0x52), .init("T", 0x54), .init("Y", 0x59), - .init("U", 0x55), .init("I", 0x49), .init("O", 0x4F), - .init("P", 0x50), .init("{\n[", 0xDB), - .init("}\n]", 0xDD), .init("|\n\\", 0xDC, width: 1.25) - ], - [ - .init("Caps", 0x14, width: 1.7, style: .special), - .init("A", 0x41), .init("S", 0x53), .init("D", 0x44), - .init("F", 0x46), .init("G", 0x47), .init("H", 0x48), - .init("J", 0x4A), .init("K", 0x4B), .init("L", 0x4C), - .init(":\n;", 0xBA), .init("\"\n'", 0xDE), - .init("回车", 0x0D, width: 1.9, style: .accent) - ], - [ - .init(symbol: "shift", 0x10, width: 2.15, isModifier: true), - .init("Z", 0x5A), .init("X", 0x58), .init("C", 0x43), - .init("V", 0x56), .init("B", 0x42), .init("N", 0x4E), - .init("M", 0x4D), .init("<\n,", 0xBC), - .init(">\n.", 0xBE), .init("?\n/", 0xBF), - .init(symbol: "shift", 0x10, width: 2.15, isModifier: true) - ], - [ - .init("Ctrl", 0x11, width: 1.35, isModifier: true), - .init("Win", 0x5B, width: 1.25, isModifier: true), - .init("Alt", 0x12, width: 1.25, isModifier: true), - .init("空格", 0x20, width: 3.8), - .init("Alt", 0x12, width: 1.25, isModifier: true), - .init("Ctrl", 0x11, width: 1.35, isModifier: true), - .init("←", 0x25, style: .special), - .init("↑", 0x26, style: .special), - .init("↓", 0x28, style: .special), - .init("→", 0x27, style: .special), - .init(symbol: "arrow.left.arrow.right", width: 1.35, - action: .switchKeyboard), - .init(symbol: "keyboard.chevron.compact.down", width: 1.35, - action: .dismiss) - ] + private static let commonRow: [RemoteComputerKey] = [ + .init("Ctrl", 0x11, isModifier: true), + .init("Win", 0x5B, isModifier: true), + .init("Alt", 0x12, isModifier: true), + .init("空格", 0x20, width: 2.6), + .init("回车", 0x0D, width: 1.4, style: .special) ] var body: some View { - VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { - ForEach(Array(Self.rows.enumerated()), id: \.offset) { _, row in - RemoteComputerKeyboardRow( - keys: row, - activeModifiers: activeModifiers, - action: handleKey - ) + VStack(spacing: 0) { + HStack(spacing: 4) { + Button("系统键盘") { + feedback.keyPressed() + releaseModifiers() + switchKeyboard() + } + .font(.system(size: 15, weight: .medium)) + .frame(width: 80, height: 44) + .accessibilityLabel("切换到系统键盘") + if verticalSizeClass == .compact { + pageTabs + } else { + Spacer(minLength: 0) + } + RemoteKeyboardDismissButton(accessibilityLabel: "收起电脑键盘") { + feedback.keyPressed() + releaseModifiers() + dismiss() + } + .frame(width: RemoteKeyboardDismissButton.sideLength, + height: RemoteKeyboardDismissButton.sideLength) + } + .padding(.horizontal, 4) + .frame(height: 44) + + if verticalSizeClass != .compact { + pageTabs + .padding(.horizontal, RemoteKeyboardMetrics.panelPadding) + } + + GeometryReader { proxy in + // Anchor both bottom rows to the input page's dimensions so + // shared controls keep their size and position across pages. + let inputRows = RemoteComputerKeyboardPage.input + .rows(compact: verticalSizeClass == .compact) + let rows = page.rows(compact: verticalSizeClass == .compact) + let inputRowCount = CGFloat(inputRows.count) + let sharedRowHeight = max(0, (proxy.size.height - + inputRowCount * RemoteKeyboardMetrics.rowSpacing) / (inputRowCount + 1)) + let inputEdgeRow = inputRows.last ?? [] + let edgeKeyWidth = max(0, proxy.size.width - + CGFloat(max(0, inputEdgeRow.count - 1)) * RemoteKeyboardMetrics.keySpacing) / + max(1, inputEdgeRow.reduce(CGFloat.zero) { $0 + $1.width }) + + VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { + VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { + ForEach(Array(rows.dropLast().enumerated()), id: \.offset) { _, row in + RemoteComputerKeyboardRow( + keys: row, + activeModifiers: activeModifiers, + action: handleKey + ) + } + } + .frame(maxHeight: .infinity) + + if let row = rows.last { + RemoteComputerKeyboardRow( + keys: row, + activeModifiers: activeModifiers, + action: handleKey, + fixedEdgeWidth: edgeKeyWidth + ) + .frame(height: sharedRowHeight) + } + + RemoteComputerKeyboardRow( + keys: Self.commonRow, + activeModifiers: activeModifiers, + action: handleKey + ) + .frame(height: sharedRowHeight) + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .padding(RemoteKeyboardMetrics.panelPadding) + } + .foregroundStyle(RemoteKeyboardPalette.keyText) + .buttonStyle(.plain) + .background(RemoteKeyboardPalette.background) + .onAppear { feedback.prepare() } + .onDisappear { + releaseModifiers() + feedback.stop() + } + } + + private var pageTabs: some View { + HStack(spacing: 6) { + ForEach(RemoteComputerKeyboardPage.allCases, id: \.self) { item in + Button { + guard page != item else { return } + feedback.keyPressed() + page = item + } label: { + Text(item.rawValue) + .font(.system(size: 14, weight: page == item ? .semibold : .regular)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(page == item ? RemoteKeyboardPalette.specialKey : .clear, + in: RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + } + .accessibilityLabel("\(item.rawValue)键盘页") + .accessibilityAddTraits(page == item ? .isSelected : []) } } - .onDisappear(perform: releaseModifiers) + .frame(height: 36) } private func handleKey(_ key: RemoteComputerKey) { + feedback.keyPressed() switch key.action { case let .modifier(keyCode): if activeModifiers.contains(keyCode) { @@ -1067,16 +953,23 @@ private struct RemoteComputerKeyboardRow: View { let keys: [RemoteComputerKey] let activeModifiers: Set let action: (RemoteComputerKey) -> Void + var fixedEdgeWidth: CGFloat? = nil var body: some View { GeometryReader { proxy in let spacing = RemoteKeyboardMetrics.keySpacing - let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width } + let edgeWidth = keys.count > 2 ? fixedEdgeWidth : nil + let flexibleKeys = edgeWidth == nil ? keys[...] : keys.dropFirst().dropLast() + let totalWidth = flexibleKeys.reduce(CGFloat.zero) { $0 + $1.width } let keySpace = max(0, proxy.size.width - + 2 * (edgeWidth ?? 0) - CGFloat(max(0, keys.count - 1)) * spacing) + let unitWidth = keySpace / totalWidth HStack(spacing: spacing) { - ForEach(Array(keys.enumerated()), id: \.offset) { _, key in + ForEach(Array(keys.enumerated()), id: \.offset) { index, key in + let width = index == 0 || index == keys.count - 1 + ? (edgeWidth ?? unitWidth * key.width) : unitWidth * key.width let isActive = key.modifierCode.map(activeModifiers.contains) ?? false let visibleSymbol = isActive && key.symbol == "shift" ? "shift.fill" : key.symbol @@ -1088,13 +981,13 @@ private struct RemoteComputerKeyboardRow: View { ) { action(key) } - .frame(width: keySpace * key.width / totalWidth) - .accessibilityLabel((key.title ?? "键盘操作") - .replacingOccurrences(of: "\n", with: " ")) + .frame(width: width) + .accessibilityLabel(key.accessibilityLabel) } } + .frame(width: proxy.size.width, height: proxy.size.height) } - .frame(height: RemoteKeyboardMetrics.rowHeight) + .frame(maxHeight: .infinity) } } diff --git a/apps/ios/README.md b/apps/ios/README.md index aeaa72c7..5725e898 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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 diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index 6b6916cc..72681625 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -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 diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index ce0d123b..d1469138 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -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( object.at("mouse").at("flag").get()); break; + case ControlType::text_input: + output.text = object.at("text").get(); + 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(); @@ -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(); + 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 =