diff --git a/OpenParsec.xcodeproj/project.pbxproj b/OpenParsec.xcodeproj/project.pbxproj index 5526ba5..0efd168 100644 --- a/OpenParsec.xcodeproj/project.pbxproj +++ b/OpenParsec.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 27E61AAA2929B92200FF6563 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E61AA92929B92200FF6563 /* MainView.swift */; }; 27ED36FF292D4F9800B1BE3D /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ED36FE292D4F9800B1BE3D /* NetworkHandler.swift */; }; 84480EBE2ADC4FDA007DE5F1 /* GameController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84480EBD2ADC4FDA007DE5F1 /* GameController.swift */; }; + C0D3F0012FDE000000000001 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C0D3F0032FDE000000000001 /* Localizable.strings */; }; E76224A12F8C017F00A2F86F /* ParsecBackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E76224A02F8C017700A2F86F /* ParsecBackgroundManager.swift */; }; E762250B2F8D0A0100A2F86F /* PictureInPictureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E762250A2F8D0A0000A2F86F /* PictureInPictureManager.swift */; }; FC16A7AD29A97BDA00BB70A7 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A7AC29A97BDA00BB70A7 /* Shared.swift */; }; @@ -94,6 +95,7 @@ 27E61AA92929B92200FF6563 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 27ED36FE292D4F9800B1BE3D /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = ""; }; 84480EBD2ADC4FDA007DE5F1 /* GameController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameController.swift; sourceTree = ""; }; + C0D3F0022FDE000000000001 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; E76224A02F8C017700A2F86F /* ParsecBackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsecBackgroundManager.swift; sourceTree = ""; }; E762250A2F8D0A0000A2F86F /* PictureInPictureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureManager.swift; sourceTree = ""; }; FC16A7AC29A97BDA00BB70A7 /* Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = ""; }; @@ -156,6 +158,7 @@ 27E61A9F292965FD00FF6563 /* Info.plist */, 17EEB91B2BEE62BA00502A3A /* KeyBoardTest.swift */, 27E61A9C292965FD00FF6563 /* LaunchScreen.storyboard */, + C0D3F0032FDE000000000001 /* Localizable.strings */, 27E61AA52929817700FF6563 /* LoginView.swift */, 27E61AA92929B92200FF6563 /* MainView.swift */, 27ED36FE292D4F9800B1BE3D /* NetworkHandler.swift */, @@ -234,6 +237,7 @@ knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = 27E61A85292965FC00FF6563; packageReferences = ( @@ -253,6 +257,7 @@ buildActionMask = 2147483647; files = ( 27E61A9E292965FD00FF6563 /* LaunchScreen.storyboard in Resources */, + C0D3F0012FDE000000000001 /* Localizable.strings in Resources */, 27E61A9B292965FD00FF6563 /* Preview Assets.xcassets in Resources */, 27E61A98292965FD00FF6563 /* Assets.xcassets in Resources */, ); @@ -329,6 +334,14 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; + C0D3F0032FDE000000000001 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + C0D3F0022FDE000000000001 /* zh-Hans */, + ); + name = Localizable.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/OpenParsec/CParsec.swift b/OpenParsec/CParsec.swift index d579ee0..17c6953 100644 --- a/OpenParsec/CParsec.swift +++ b/OpenParsec/CParsec.swift @@ -103,7 +103,7 @@ enum ParsecResolution: String, CaseIterable, Hashable { } var desc: String { - return rawValue + return localized(rawValue) } static var resolutions: [ParsecResolution] { @@ -153,6 +153,7 @@ protocol ParsecService { func sendKeyboardMessage(keyCode: UInt32, pressed: Bool) func sendVirtualKeyboardInput(text: String) func sendVirtualKeyboardInput(text: String, isOn: Bool) + func sendKeyboardShortcut(modifier: ShortcutModifier, key: String) func sendGameControllerButtonMessage(controllerId: UInt32, _ button: ParsecGamepadButton, pressed: Bool) func sendGameControllerAxisMessage(controllerId: UInt32, _ button: ParsecGamepadAxis, _ value: Int16) func sendGameControllerUnplugMessage(controllerId: UInt32) @@ -259,6 +260,10 @@ class CParsec { parsecImpl.sendVirtualKeyboardInput(text: text, isOn: isOn) } + static func sendKeyboardShortcut(modifier: ShortcutModifier, key: String) { + parsecImpl.sendKeyboardShortcut(modifier: modifier, key: key) + } + static func sendGameControllerButtonMessage(controllerId: UInt32, _ button: ParsecGamepadButton, pressed: Bool) { parsecImpl.sendGameControllerButtonMessage(controllerId: controllerId, button, pressed: pressed) } diff --git a/OpenParsec/ExUI.swift b/OpenParsec/ExUI.swift index 698b472..9ff8c1b 100644 --- a/OpenParsec/ExUI.swift +++ b/OpenParsec/ExUI.swift @@ -14,7 +14,7 @@ struct CatTitle: View { var body: some View { HStack { - Text(text) + Text(localized(text)) Spacer() } .padding(.horizontal) @@ -60,7 +60,7 @@ struct CatItem: View { var body: some View { HStack { - Text(title) + Text(localized(title)) .lineLimit(1) Spacer() content() @@ -79,7 +79,7 @@ struct Choice { var value: T init(_ label: String, _ value: T) { - self.label = label + self.label = localized(label) self.value = value } } @@ -118,7 +118,7 @@ struct MultiPicker: View { var options: [Choice] @State var showChoices: Bool = false - @State var valueText: String = "Choose..." + @State var valueText: String = localized("Choose...") init(selection: Binding, options: [Choice]) { self.selection = selection @@ -163,7 +163,7 @@ struct MultiPicker: View { let buttons = options.enumerated().map { _, option in Alert.Button.default(Text(option.value == selection.wrappedValue ? " \(option.label) ✓" : option.label), action: {select(option)}) } - return ActionSheet(title: Text("Pick your preference:"), buttons: buttons + [Alert.Button.cancel()]) + return ActionSheet(title: Text(localized("Pick your preference:")), buttons: buttons + [Alert.Button.cancel()]) } func select(_ option: Choice) { diff --git a/OpenParsec/LoginView.swift b/OpenParsec/LoginView.swift index c8841a9..7a7d605 100644 --- a/OpenParsec/LoginView.swift +++ b/OpenParsec/LoginView.swift @@ -195,7 +195,7 @@ struct LoginView: View { if isTFARequired { presentTFAAlert = true } else { - alertText = "Error: \(info)" + alertText = localized("Error: %@", String(describing: info)) showAlert = true } } else { diff --git a/OpenParsec/MainView.swift b/OpenParsec/MainView.swift index ffab037..0ee5a14 100644 --- a/OpenParsec/MainView.swift +++ b/OpenParsec/MainView.swift @@ -7,13 +7,13 @@ struct MainView: View { @State private var page: Page = .hosts // Host page vars - @State var hostCountStr: String = "0 hosts" - @State var refreshTime: String = "Last refreshed at 1/1/1970 12:00 AM" + @State var hostCountStr: String = localized("%d hosts", 0) + @State var refreshTime: String = localized("Last refreshed at %@", "1/1/1970 12:00 AM") @State var hosts: [IdentifiableHostInfo] = [] // Friend page vars - @State var friendCountStr: String = "0 friends" + @State var friendCountStr: String = localized("%d friends", 0) @State var userInfo: IdentifiableUserInfo? @State var friends: [IdentifiableUserInfo] = [] @@ -309,7 +309,7 @@ struct MainView: View { VStack { ActivityIndicator(isAnimating: $isConnecting, style: .large, tint: .white) .padding() - Text("Requesting connection to \(connectingToName)...") + Text(localized("Requesting connection to %@...", connectingToName)) .multilineTextAlignment(.center) Button(action: cancelConnection) { ZStack { @@ -371,7 +371,7 @@ struct MainView: View { let clinfo = NetworkHandler.clinfo if clinfo == nil { isRefreshing = false - baseAlertText = "Error gathering hosts: Invalid session" + baseAlertText = localized("Error gathering hosts: Invalid session") showBaseAlert = true return } @@ -399,20 +399,16 @@ struct MainView: View { } } - var grammar: String = "hosts" - if hosts.count == 1 { - grammar = "host" - } - - hostCountStr = "\(hosts.count) \(grammar)" + hostCountStr = localized(hosts.count == 1 ? "%d host" : "%d hosts", hosts.count) let formatter = DateFormatter() + formatter.locale = Locale.current formatter.dateFormat = "M/d/yyyy h:mm a" - refreshTime = "Last refreshed at \(formatter.string(from: Date()))" + refreshTime = localized("Last refreshed at %@", formatter.string(from: Date())) } else if statusCode == 403 { // 403 Forbidden guard let info: ErrorInfo = try? decoder.decode(ErrorInfo.self, from: data) else { return } - baseAlertText = "Error gathering hosts: \(info.error)" + baseAlertText = localized("Error gathering hosts: %@", info.error) showBaseAlert = true } } @@ -451,7 +447,7 @@ struct MainView: View { } else { guard let info: ErrorInfo = try? decoder.decode(ErrorInfo.self, from: data) else { return } - baseAlertText = "Error gathering user info: \(info.error)" + baseAlertText = localized("Error gathering user info: %@", info.error) showBaseAlert = true } } @@ -494,16 +490,11 @@ struct MainView: View { } } - var grammar: String = "friends" - if friends.count == 1 { - grammar = "friend" - } - - friendCountStr = "\(friends.count) \(grammar)" + friendCountStr = localized(friends.count == 1 ? "%d friend" : "%d friends", friends.count) } else { guard let info: ErrorInfo = try? decoder.decode(ErrorInfo.self, from: data) else { return } - baseAlertText = "Error gathering friends: \(info.error)" + baseAlertText = localized("Error gathering friends: %@", info.error) showBaseAlert = true } } @@ -535,7 +526,7 @@ struct MainView: View { c.setView(.parsec) } } else { - baseAlertText = "Error connecting to host (code \(status.rawValue))" + baseAlertText = localized("Error connecting to host (code %d)", Int(status.rawValue)) showBaseAlert = true } diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 09a1a7f..80306be 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -17,6 +17,25 @@ enum CursorMode: Int { case direct } +enum DirectDragMode: Int { + case scroll + case drag +} + +enum ShortcutModifier: Int { + case control + case command + + var keyText: String { + switch self { + case .control: + return "CONTROL" + case .command: + return "LGUI" + } + } +} + enum RightClickPosition: Int { case firstFinger case middle @@ -423,6 +442,21 @@ class ParsecSDKBridge: ParsecService { } + func sendKeyboardShortcut(modifier: ShortcutModifier, key: String) { + guard let modifierKeyCode = KeyCodeTranslators.parsecKeyCodeTranslator(modifier.keyText), + let keyCode = KeyCodeTranslators.parsecKeyCodeTranslator(key.uppercased()) else { + return + } + + sendKeyboardMessage(keyCode: modifierKeyCode.rawValue, pressed: true) + sendKeyboardMessage(keyCode: keyCode.rawValue, pressed: true) + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.02) { + self.sendKeyboardMessage(keyCode: keyCode.rawValue, pressed: false) + self.sendKeyboardMessage(keyCode: modifierKeyCode.rawValue, pressed: false) + } + } + func sendKeyboardMessage(event: KeyBoardKeyEvent) { if event.input == nil { return diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 4a3ec9d..3bcd44f 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -5,7 +5,7 @@ import AVFoundation struct ParsecStatusBar: View { @Binding var showMenu: Bool - @State var metricInfo: String = "Loading..." + @State var metricInfo: String = localized("Loading...") @Binding var showDCAlert: Bool @Binding var DCAlertText: String @State var parsecViewController: ParsecViewController? @@ -72,14 +72,25 @@ struct ParsecStatusBar: View { } wasDisconnected = true - DCAlertText = "Disconnected (code \(status.rawValue))" + DCAlertText = localized("Disconnected (code %d)", Int(status.rawValue)) showDCAlert = true return } if showMenu { let str = String.fromBuffer(&pcs.decoder.0.name.0, length: 16) - metricInfo = "Decode \(String(format: "%.2f", pcs.`self`.metrics.0.decodeLatency))ms Encode \(String(format: "%.2f", pcs.`self`.metrics.0.encodeLatency))ms Network \(String(format: "%.2f", pcs.`self`.metrics.0.networkLatency))ms Bitrate \(String(format: "%.2f", pcs.`self`.metrics.0.bitrate))Mbps \(pcs.decoder.0.h265 ? "H265" : "H264") \(pcs.decoder.0.width)x\(pcs.decoder.0.height) \(pcs.decoder.0.color444 ? "4:4:4" : "4:2:0") \(str)" + metricInfo = localized( + "Decode %@ms Encode %@ms Network %@ms Bitrate %@Mbps %@ %@x%@ %@ %@", + String(format: "%.2f", pcs.`self`.metrics.0.decodeLatency), + String(format: "%.2f", pcs.`self`.metrics.0.encodeLatency), + String(format: "%.2f", pcs.`self`.metrics.0.networkLatency), + String(format: "%.2f", pcs.`self`.metrics.0.bitrate), + pcs.decoder.0.h265 ? "H265" : "H264", + String(pcs.decoder.0.width), + String(pcs.decoder.0.height), + pcs.decoder.0.color444 ? "4:4:4" : "4:2:0", + str + ) } } } @@ -98,8 +109,8 @@ struct ParsecView: View { var controller: ContentView? @State var showDCAlert: Bool = false - @State var DCAlertText: String = "Disconnected (reason unknown)" - @State var metricInfo: String = "Loading..." + @State var DCAlertText: String = localized("Disconnected (reason unknown)") + @State var metricInfo: String = localized("Loading...") @State var hideOverlay: Bool = false @State var showMenu: Bool = false @@ -204,8 +215,22 @@ struct ParsecView: View { .frame(maxWidth: .infinity) .multilineTextAlignment(.center) } + HStack(spacing: 3) { + Button(action: sendCopyShortcut) { + Label("Copy", systemImage: "doc.on.doc") + .padding(8) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + Button(action: sendPasteShortcut) { + Label("Paste", systemImage: "doc.on.clipboard") + .padding(8) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } Button(action: toggleMute) { - Text("Sound: \(muted ? "OFF" : "ON")") + Text(localized("Sound: %@", localized(muted ? "OFF" : "ON"))) .padding(8) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) @@ -265,13 +290,13 @@ struct ParsecView: View { } Button(action: toggleConstantFps) { - Text("Constant FPS: \(constantFps ? "ON" : "OFF")") + Text(localized("Constant FPS: %@", localized(constantFps ? "ON" : "OFF"))) .padding(8) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) } Button(action: toggleZoom) { - Text("Zoom: \(zoomEnabled ? "ON" : "OFF")") + Text(localized("Zoom: %@", localized(zoomEnabled ? "ON" : "OFF"))) .padding(8) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) @@ -472,6 +497,14 @@ struct ParsecView: View { } } + func sendCopyShortcut() { + CParsec.sendKeyboardShortcut(modifier: SettingsHandler.shortcutModifier, key: "C") + } + + func sendPasteShortcut() { + CParsec.sendKeyboardShortcut(modifier: SettingsHandler.shortcutModifier, key: "V") + } + func toggleZoom() { DispatchQueue.main.async { zoomEnabled.toggle() diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index ce561e1..80376c3 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -14,15 +14,26 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var gamePadController: GamepadController! var touchController: TouchController! var u: UIImageView? + var directTouchIndicatorImage: UIImage? + var directTouchIndicatorVisible = false var lastImg: CGImage? var lastMouseX: Int32 = -1 var lastMouseY: Int32 = -1 var lastCursorHidden: Bool = false + var lastCursorMode: CursorMode = .touchpad var isPinching = false var zoomEnabled = false var lastLongPressPoint: CGPoint = CGPoint() var accumulatedDeltaX: Float = 0.0 var accumulatedDeltaY: Float = 0.0 + var accumulatedWheelY: Float = 0.0 + let directScrollDivisor: Float = 0.5 + let directTouchIndicatorSize: CGFloat = 22.0 + let directLongPressDragThreshold: CGFloat = 8.0 + var directLongPressActive = false + var directLongPressStartPoint: CGPoint = .zero + var directLongPressDidMove = false + var directLongPressDragging = false var lastPanLocation: CGPoint = .zero var lastPanTranslation: CGPoint = .zero @@ -63,20 +74,33 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { let currentMouseY = CParsec.mouseInfo.mouseY let currentHidden = CParsec.mouseInfo.cursorHidden let currentImg = CParsec.mouseInfo.cursorImg + let currentCursorMode = SettingsHandler.cursorMode // Skip if nothing changed if currentMouseX == lastMouseX && currentMouseY == lastMouseY && currentHidden == lastCursorHidden && - currentImg == lastImg { + currentImg == lastImg && + currentCursorMode == lastCursorMode { return } lastMouseX = currentMouseX lastMouseY = currentMouseY lastCursorHidden = currentHidden + lastCursorMode = currentCursorMode + if currentCursorMode == .direct { + if !directTouchIndicatorVisible { + u?.isHidden = true + } + lastImg = nil + return + } + + directTouchIndicatorVisible = false if currentImg != nil && !currentHidden { + u?.isHidden = false if lastImg != currentImg { u!.image = UIImage(cgImage: currentImg!) lastImg = currentImg! @@ -468,16 +492,9 @@ extension ParsecViewController: UIGestureRecognizerDelegate { // lock activatedPanFingerNumber in case user not releasing both finger at the same time if gestureRecognizer.numberOfTouches == 0 { if gestureRecognizer.state == .ended || gestureRecognizer.state == .cancelled { - activatedPanFingerNumber = 0 - // Reset accumulators - accumulatedDeltaX = 0.0 - accumulatedDeltaY = 0.0 - lastPanTranslation = .zero - - if SettingsHandler.cursorMode == .direct { - let button = ParsecMouseButton.init(rawValue: 1) - CParsec.sendMouseClickMessage(button, false) - } + resetPanTracking() + releaseDirectDragIfNeeded() + hideDirectTouchIndicator() } } else if activatedPanFingerNumber == 2 || (gestureRecognizer.numberOfTouches == 2 && activatedPanFingerNumber == 0) { // Native UIScrollView handles 2-finger pan for scrolling. @@ -500,49 +517,144 @@ extension ParsecViewController: UIGestureRecognizerDelegate { } } else if activatedPanFingerNumber == 1 || (gestureRecognizer.numberOfTouches == 1 && activatedPanFingerNumber == 0) { activatedPanFingerNumber = 1 - // move mouse if SettingsHandler.cursorMode == .direct { - // Map screen tap to content coordinates - let position = gestureRecognizer.location(in: gestureRecognizer.view) - // Convert to content coordinates - let adjustedPosition = contentView.convert(position, from: view) - CParsec.sendMousePosition(Int32(adjustedPosition.x), Int32(adjustedPosition.y)) + if SettingsHandler.directDragMode == .scroll { + handleDirectPanAsScroll(gestureRecognizer) + } else { + handleDirectPanAsDrag(gestureRecognizer) + } } else { - // Simple translation-based movement with sub-pixel accumulation - let currentTranslation = gestureRecognizer.translation(in: gestureRecognizer.view) + handleTouchpadPan(gestureRecognizer) + } - if gestureRecognizer.state == .began { - lastPanTranslation = .zero - accumulatedDeltaX = 0.0 - accumulatedDeltaY = 0.0 - } + } + } - // Calculate delta since last update - let deltaX = Float(currentTranslation.x - lastPanTranslation.x) * mouseSensitivity - let deltaY = Float(currentTranslation.y - lastPanTranslation.y) * mouseSensitivity + func resetPanTracking() { + activatedPanFingerNumber = 0 + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 + accumulatedWheelY = 0.0 + lastPanTranslation = .zero + } + + func releaseDirectDragIfNeeded() { + guard SettingsHandler.cursorMode == .direct else { return } + if SettingsHandler.directDragMode == .drag || directLongPressDragging { + let button = ParsecMouseButton.init(rawValue: 1) + CParsec.sendMouseClickMessage(button, false) + directLongPressDragging = false + } + } - lastPanTranslation = currentTranslation + func handleDirectPanAsDrag(_ gestureRecognizer: UIPanGestureRecognizer) { + let location = gestureRecognizer.location(in: gestureRecognizer.view) + moveDirectMouse(to: location) + + if gestureRecognizer.state == .began { + showDirectTouchIndicator(at: location) + let button = ParsecMouseButton.init(rawValue: 1) + CParsec.sendMouseClickMessage(button, true) + } else if gestureRecognizer.state == .changed || gestureRecognizer.state == .ended || gestureRecognizer.state == .cancelled || gestureRecognizer.state == .failed { + hideDirectTouchIndicator() + } + } - // Accumulate for sub-pixel precision - accumulatedDeltaX += deltaX - accumulatedDeltaY += deltaY + func handleDirectPanAsScroll(_ gestureRecognizer: UIPanGestureRecognizer) { + if directLongPressActive || directLongPressDragging { + return + } - // Send movement when we have at least 1 pixel - let intDeltaX = Int32(accumulatedDeltaX) - let intDeltaY = Int32(accumulatedDeltaY) + if gestureRecognizer.state == .began { + let location = gestureRecognizer.location(in: gestureRecognizer.view) + showDirectTouchIndicator(at: location) + moveDirectMouse(to: location) + lastPanTranslation = .zero + accumulatedWheelY = 0.0 + } else if gestureRecognizer.state == .changed || gestureRecognizer.state == .ended || gestureRecognizer.state == .cancelled || gestureRecognizer.state == .failed { + hideDirectTouchIndicator() + } - if intDeltaX != 0 || intDeltaY != 0 { - CParsec.sendMouseDelta(intDeltaX, intDeltaY) - accumulatedDeltaX -= Float(intDeltaX) - accumulatedDeltaY -= Float(intDeltaY) - } - } + let currentTranslation = gestureRecognizer.translation(in: gestureRecognizer.view) + let deltaY = Float(currentTranslation.y - lastPanTranslation.y) * mouseSensitivity + lastPanTranslation = currentTranslation - if gestureRecognizer.state == .began && SettingsHandler.cursorMode == .direct { - let button = ParsecMouseButton.init(rawValue: 1) - CParsec.sendMouseClickMessage(button, true) - } + accumulatedWheelY += deltaY / directScrollDivisor + let wheelY = Int32(accumulatedWheelY) + if wheelY != 0 { + CParsec.sendWheelMsg(x: 0, y: wheelY) + accumulatedWheelY -= Float(wheelY) + } + } + + func handleTouchpadPan(_ gestureRecognizer: UIPanGestureRecognizer) { + // Simple translation-based movement with sub-pixel accumulation + let currentTranslation = gestureRecognizer.translation(in: gestureRecognizer.view) + + if gestureRecognizer.state == .began { + lastPanTranslation = .zero + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 + } + + // Calculate delta since last update + let deltaX = Float(currentTranslation.x - lastPanTranslation.x) * mouseSensitivity + let deltaY = Float(currentTranslation.y - lastPanTranslation.y) * mouseSensitivity + + lastPanTranslation = currentTranslation + + // Accumulate for sub-pixel precision + accumulatedDeltaX += deltaX + accumulatedDeltaY += deltaY + + // Send movement when we have at least 1 pixel + let intDeltaX = Int32(accumulatedDeltaX) + let intDeltaY = Int32(accumulatedDeltaY) + + if intDeltaX != 0 || intDeltaY != 0 { + CParsec.sendMouseDelta(intDeltaX, intDeltaY) + accumulatedDeltaX -= Float(intDeltaX) + accumulatedDeltaY -= Float(intDeltaY) + } + } + + func moveDirectMouse(to position: CGPoint) { + let adjustedPosition = contentView.convert(position, from: view) + CParsec.sendMousePosition(Int32(adjustedPosition.x), Int32(adjustedPosition.y)) + } + + func showDirectTouchIndicator(at position: CGPoint) { + guard SettingsHandler.cursorMode == .direct else { return } + if directTouchIndicatorImage == nil { + directTouchIndicatorImage = makeDirectTouchIndicatorImage() + } + + let adjustedPosition = contentView.convert(position, from: view) + directTouchIndicatorVisible = true + u?.image = directTouchIndicatorImage + u?.isHidden = false + u?.frame = CGRect(x: adjustedPosition.x - directTouchIndicatorSize / 2, + y: adjustedPosition.y - directTouchIndicatorSize / 2, + width: directTouchIndicatorSize, + height: directTouchIndicatorSize) + } + + func hideDirectTouchIndicator() { + directTouchIndicatorVisible = false + if SettingsHandler.cursorMode == .direct { + u?.isHidden = true + } + } + + func makeDirectTouchIndicatorImage() -> UIImage { + let size = CGSize(width: directTouchIndicatorSize, height: directTouchIndicatorSize) + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { context in + let bounds = CGRect(origin: .zero, size: size) + UIColor.white.withAlphaComponent(0.55).setFill() + context.cgContext.fillEllipse(in: bounds) } } @@ -551,6 +663,7 @@ extension ParsecViewController: UIGestureRecognizerDelegate { let location = gestureRecognizer.location(in: gestureRecognizer.view) let adjustedLocation = contentView.convert(location, from: view) touchController.onTap(typeOfTap: 1, location: adjustedLocation) + hideDirectTouchIndicator() } @objc func handleTwoFingerTap(_ gestureRecognizer: UITapGestureRecognizer) { @@ -567,6 +680,7 @@ extension ParsecViewController: UIGestureRecognizerDelegate { let adjustedLocation = contentView.convert(location, from: view) touchController.onTap(typeOfTap: 3, location: adjustedLocation) + hideDirectTouchIndicator() } @objc func handleThreeFinderTap(_ gestureRecognizer: UITapGestureRecognizer) { @@ -574,6 +688,11 @@ extension ParsecViewController: UIGestureRecognizerDelegate { } @objc func handleLongPress(_ gestureRecognizer: UIGestureRecognizer) { + if SettingsHandler.cursorMode == .direct && SettingsHandler.directDragMode == .scroll { + handleDirectLongPressDrag(gestureRecognizer) + return + } + if SettingsHandler.cursorMode != .touchpad { return } @@ -596,6 +715,52 @@ extension ParsecViewController: UIGestureRecognizerDelegate { } } + func handleDirectLongPressDrag(_ gestureRecognizer: UIGestureRecognizer) { + let button = ParsecMouseButton.init(rawValue: 1) + let location = gestureRecognizer.location(in: gestureRecognizer.view) + + if gestureRecognizer.state == .began { + directLongPressActive = true + directLongPressStartPoint = location + directLongPressDidMove = false + directLongPressDragging = false + showDirectTouchIndicator(at: location) + moveDirectMouse(to: location) + } else if gestureRecognizer.state == .changed { + let deltaX = location.x - directLongPressStartPoint.x + let deltaY = location.y - directLongPressStartPoint.y + let distance = hypot(deltaX, deltaY) + + if !directLongPressDragging && distance >= directLongPressDragThreshold { + directLongPressDidMove = true + directLongPressDragging = true + moveDirectMouse(to: directLongPressStartPoint) + CParsec.sendMouseClickMessage(button, true) + } + + if directLongPressDragging { + moveDirectMouse(to: location) + } + } else if gestureRecognizer.state == .ended || gestureRecognizer.state == .cancelled || gestureRecognizer.state == .failed { + if directLongPressDragging { + CParsec.sendMouseClickMessage(button, false) + } else if gestureRecognizer.state == .ended && !directLongPressDidMove { + handleDirectLongPressRightClick(at: location) + } + + directLongPressActive = false + directLongPressDragging = false + directLongPressDidMove = false + hideDirectTouchIndicator() + } + } + + func handleDirectLongPressRightClick(at location: CGPoint) { + let adjustedLocation = contentView.convert(location, from: view) + touchController.onTap(typeOfTap: 3, location: adjustedLocation) + hideDirectTouchIndicator() + } + // UIScrollViewDelegate func viewForZooming(in scrollView: UIScrollView) -> UIView? { return contentView @@ -760,7 +925,7 @@ extension ParsecViewController: UIKeyInput, UITextInputTraits { let doneButton = UIButton(type: .system) doneButton.frame = CGRect(x: toolbarBackground.bounds.width - 70, y: 0, width: 60, height: 44) doneButton.autoresizingMask = [.flexibleLeftMargin] - doneButton.setTitle("Done", for: .normal) + doneButton.setTitle(localized("Done"), for: .normal) doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside) toolbarBackground.addSubview(scrollView) diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index f6230bc..bd3b1c6 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -7,8 +7,10 @@ struct SettingsHandler { @AppStorage("bitrate") public static var bitrate: Int = 0 @AppStorage("decoder") public static var decoder: DecoderPref = .h264 @AppStorage("cursorMode") public static var cursorMode: CursorMode = .touchpad + @AppStorage("directDragMode") public static var directDragMode: DirectDragMode = .scroll @AppStorage("cursorScale") public static var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") public static var mouseSensitivity: Double = 1.0 + @AppStorage("shortcutModifier") public static var shortcutModifier: ShortcutModifier = .control @AppStorage("noOverlay") public static var noOverlay: Bool = false @AppStorage("hideStatusBar") public static var hideStatusBar: Bool = true @AppStorage("rightClickPosition") public static var rightClickPosition: RightClickPosition = .firstFinger diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 34016a1..466678e 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -8,8 +8,10 @@ struct SettingsView: View { @AppStorage("bitrate") var bitrate: Int = 0 @AppStorage("decoder") var decoder: DecoderPref = .h264 @AppStorage("cursorMode") var cursorMode: CursorMode = .touchpad + @AppStorage("directDragMode") var directDragMode: DirectDragMode = .scroll @AppStorage("cursorScale") var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") var mouseSensitivity: Double = 1.0 + @AppStorage("shortcutModifier") var shortcutModifier: ShortcutModifier = .control @AppStorage("noOverlay") var noOverlay: Bool = false @AppStorage("hideStatusBar") var hideStatusBar: Bool = true @AppStorage("rightClickPosition") var rightClickPosition: RightClickPosition = .firstFinger @@ -76,6 +78,13 @@ struct SettingsView: View { Choice("Direct", CursorMode.direct) ]) } + CatItem("Direct Drag") { + MultiPicker(selection: $directDragMode, options: + [ + Choice("Scroll", DirectDragMode.scroll), + Choice("Drag", DirectDragMode.drag) + ]) + } CatItem("Right Click Position") { MultiPicker(selection: $rightClickPosition, options: [ @@ -84,6 +93,13 @@ struct SettingsView: View { Choice("Second Finger", RightClickPosition.secondFinger) ]) } + CatItem("Shortcut Modifier") { + MultiPicker(selection: $shortcutModifier, options: + [ + Choice("Control", ShortcutModifier.control), + Choice("Command", ShortcutModifier.command) + ]) + } CatItem("Cursor Scale") { Slider(value: $cursorScale, in: 0.1...4, step: 0.1) .frame(width: 200) @@ -173,7 +189,9 @@ struct SettingsView: View { } func getVersionInfo() -> String { - return "Version \(Bundle.main.infoDictionary!["CFBundleShortVersionString"] ?? "Unknown versino")-\(Bundle.main.infoDictionary!["GitCommitInfo"] ?? "Unknown commit")" + let version = String(describing: Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? localized("Unknown versino")) + let commit = String(describing: Bundle.main.infoDictionary?["GitCommitInfo"] ?? localized("Unknown commit")) + return localized("Version %@-%@", version, commit) } } diff --git a/OpenParsec/Shared.swift b/OpenParsec/Shared.swift index 8ec428a..122d35f 100644 --- a/OpenParsec/Shared.swift +++ b/OpenParsec/Shared.swift @@ -3,6 +3,14 @@ import SwiftUI var appScheme: ColorScheme = .dark +func localized(_ key: String, _ arguments: CVarArg...) -> String { + let format = NSLocalizedString(key, comment: "") + if arguments.isEmpty { + return format + } + return String(format: format, locale: Locale.current, arguments: arguments) +} + struct GLBData { let SessionKeyChainKey = "OPStoredAuthData" } diff --git a/OpenParsec/zh-Hans.lproj/Localizable.strings b/OpenParsec/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..41bb2e3 --- /dev/null +++ b/OpenParsec/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,90 @@ +"Email" = "邮箱"; +"Password" = "密码"; +"Login" = "登录"; +"Loading..." = "加载中..."; +"Please enter your 2FA code from your authenticator app" = "请输入身份验证器应用中的 2FA 代码"; +"2FA Code" = "2FA 代码"; +"Cancel" = "取消"; +"Enter" = "确认"; +"Login Failed" = "登录失败"; +"Error: %@" = "错误:%@"; + +"Are you sure you want to logout?" = "确定要退出登录吗?"; +"Logout" = "退出登录"; +"Connect" = "连接"; +"You" = "你"; +"Friends" = "好友"; +"Hosts" = "主机"; +"%d host" = "%d 台主机"; +"%d hosts" = "%d 台主机"; +"%d friend" = "%d 位好友"; +"%d friends" = "%d 位好友"; +"Last refreshed at %@" = "上次刷新:%@"; +"Requesting connection to %@..." = "正在请求连接到 %@..."; +"Refreshing hosts..." = "正在刷新主机..."; +"Error gathering hosts: Invalid session" = "获取主机失败:会话无效"; +"Error gathering hosts: %@" = "获取主机失败:%@"; +"Error gathering user info: %@" = "获取用户信息失败:%@"; +"Error gathering friends: %@" = "获取好友失败:%@"; +"Error connecting to host (code %d)" = "连接主机失败(代码 %d)"; + +"Settings" = "设置"; +"Interactivity" = "交互"; +"Mouse Movement" = "鼠标移动"; +"Touchpad" = "触控板"; +"Direct" = "直接"; +"Direct Drag" = "直接模式拖动"; +"Scroll" = "滚动"; +"Drag" = "拖动"; +"Right Click Position" = "右键位置"; +"First Finger" = "第一根手指"; +"Middle" = "中间"; +"Second Finger" = "第二根手指"; +"Cursor Scale" = "光标缩放"; +"Mouse Sensitivity" = "鼠标灵敏度"; +"Shortcut Modifier" = "快捷键修饰键"; +"Control" = "Control"; +"Command" = "Command"; +"Graphics" = "图形"; +"Default Resolution" = "默认分辨率"; +"Host Resolution" = "主机分辨率"; +"Client Resolution" = "客户端分辨率"; +"Decoder" = "解码器"; +"H.264" = "H.264"; +"Prefer H.265" = "优先使用 H.265"; +"Frame Rate" = "帧率"; +"Auto (Device Max)" = "自动(设备上限)"; +"120 FPS" = "120 FPS"; +"60 FPS" = "60 FPS"; +"30 FPS" = "30 FPS"; +"Decoder Compatibility" = "解码器兼容性"; +"Misc" = "其他"; +"Never Show Overlay" = "永不显示浮层"; +"Hide Status Bar" = "隐藏状态栏"; +"Show Keyboard Button" = "显示键盘按钮"; +"Save Session Settings" = "保存会话设置"; +"Version %@-%@" = "版本 %@-%@"; +"Unknown versino" = "未知版本"; +"Unknown commit" = "未知提交"; + +"Disconnected (reason unknown)" = "已断开连接(原因未知)"; +"Disconnected (code %d)" = "已断开连接(代码 %d)"; +"Decode %@ms Encode %@ms Network %@ms Bitrate %@Mbps %@ %@x%@ %@ %@" = "解码 %@ms 编码 %@ms 网络 %@ms 码率 %@Mbps %@ %@x%@ %@ %@"; +"Close" = "关闭"; +"Hide Overlay" = "隐藏浮层"; +"Copy" = "复制"; +"Paste" = "粘贴"; +"Sound: %@" = "声音:%@"; +"OFF" = "关闭"; +"ON" = "开启"; +"Resolution" = "分辨率"; +"Bitrate" = "码率"; +"Auto" = "自动"; +"Switch Display" = "切换显示器"; +"Constant FPS: %@" = "固定帧率:%@"; +"Zoom: %@" = "缩放:%@"; +"Disconnect" = "断开连接"; + +"Pick your preference:" = "请选择偏好:"; +"Choose..." = "选择..."; +"Done" = "完成"; diff --git a/scripts/check_input_preferences.sh b/scripts/check_input_preferences.sh new file mode 100755 index 0000000..ecfbab6 --- /dev/null +++ b/scripts/check_input_preferences.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +missing=0 + +require_in_file() { + pattern="$1" + file="$2" + if ! /usr/bin/grep -Fq "$pattern" "$file"; then + echo "Missing '$pattern' in $file" + missing=1 + fi +} + +forbid_in_file() { + pattern="$1" + file="$2" + if /usr/bin/grep -Fq "$pattern" "$file"; then + echo "Unexpected '$pattern' in $file" + missing=1 + fi +} + +require_in_file "enum DirectDragMode" "OpenParsec/ParsecSDKBridge.swift" +require_in_file "enum ShortcutModifier" "OpenParsec/ParsecSDKBridge.swift" +require_in_file "@AppStorage(\"directDragMode\") public static var directDragMode: DirectDragMode = .scroll" "OpenParsec/SettingsHandler.swift" +require_in_file "@AppStorage(\"shortcutModifier\") public static var shortcutModifier: ShortcutModifier = .control" "OpenParsec/SettingsHandler.swift" +require_in_file "CatItem(\"Direct Drag\")" "OpenParsec/SettingsView.swift" +require_in_file "CatItem(\"Shortcut Modifier\")" "OpenParsec/SettingsView.swift" +require_in_file "handleDirectPanAsScroll" "OpenParsec/ParsecViewController.swift" +require_in_file "let directScrollDivisor: Float = 0.5" "OpenParsec/ParsecViewController.swift" +require_in_file "directLongPressActive" "OpenParsec/ParsecViewController.swift" +require_in_file "directLongPressStartPoint" "OpenParsec/ParsecViewController.swift" +require_in_file "directLongPressDidMove" "OpenParsec/ParsecViewController.swift" +require_in_file "handleDirectLongPressRightClick" "OpenParsec/ParsecViewController.swift" +require_in_file "sendKeyboardShortcut" "OpenParsec/ParsecSDKBridge.swift" +require_in_file "SettingsHandler.shortcutModifier" "OpenParsec/ParsecView.swift" +require_in_file "sendCopyShortcut" "OpenParsec/ParsecView.swift" +require_in_file "sendPasteShortcut" "OpenParsec/ParsecView.swift" +require_in_file "showDirectTouchIndicator" "OpenParsec/ParsecViewController.swift" +require_in_file "hideDirectTouchIndicator" "OpenParsec/ParsecViewController.swift" +require_in_file "makeDirectTouchIndicatorImage" "OpenParsec/ParsecViewController.swift" +require_in_file "UIColor.white.withAlphaComponent(0.55).setFill()" "OpenParsec/ParsecViewController.swift" +require_in_file "\"Direct Drag\"" "OpenParsec/zh-Hans.lproj/Localizable.strings" +require_in_file "\"Shortcut Modifier\"" "OpenParsec/zh-Hans.lproj/Localizable.strings" +require_in_file "\"Copy\"" "OpenParsec/zh-Hans.lproj/Localizable.strings" +require_in_file "\"Paste\"" "OpenParsec/zh-Hans.lproj/Localizable.strings" + +forbid_in_file "autoShowKeyboardOnTap" "OpenParsec/SettingsHandler.swift" +forbid_in_file "autoShowKeyboardOnTap" "OpenParsec/SettingsView.swift" +forbid_in_file "showKeyboardIfEnabledAfterTap" "OpenParsec/ParsecViewController.swift" +forbid_in_file "\"Auto Show Keyboard\"" "OpenParsec/zh-Hans.lproj/Localizable.strings" +forbid_in_file "DataManager.model.hostMacOS" "OpenParsec/ParsecSDKBridge.swift" +forbid_in_file "shortcutModifierForHost" "OpenParsec/Shared.swift" + +if [ "$missing" -ne 0 ]; then + exit 1 +fi + +echo "Input preference implementation contains required hooks." diff --git a/scripts/check_localizations.sh b/scripts/check_localizations.sh new file mode 100755 index 0000000..2149680 --- /dev/null +++ b/scripts/check_localizations.sh @@ -0,0 +1,108 @@ +#!/bin/sh +set -eu + +strings_file="OpenParsec/zh-Hans.lproj/Localizable.strings" + +if [ ! -f "$strings_file" ]; then + echo "Missing $strings_file" + exit 1 +fi + +missing=0 + +while IFS= read -r key; do + [ -n "$key" ] || continue + if ! /usr/bin/grep -Fq "\"$key\"" "$strings_file"; then + echo "Missing localization key: $key" + missing=1 + fi +done <<'KEYS' +Email +Password +Login +Loading... +Please enter your 2FA code from your authenticator app +2FA Code +Cancel +Enter +Login Failed +Are you sure you want to logout? +Logout +Connect +You +Friends +Hosts +%d host +%d hosts +%d friend +%d friends +Last refreshed at %@ +Requesting connection to %@... +Refreshing hosts... +Error gathering hosts: Invalid session +Error gathering hosts: %@ +Error gathering user info: %@ +Error gathering friends: %@ +Error connecting to host (code %d) +Error: %@ +Settings +Interactivity +Mouse Movement +Touchpad +Direct +Direct Drag +Scroll +Drag +Right Click Position +First Finger +Middle +Second Finger +Cursor Scale +Mouse Sensitivity +Shortcut Modifier +Control +Command +Graphics +Default Resolution +Host Resolution +Client Resolution +Decoder +Prefer H.265 +Frame Rate +Auto (Device Max) +Decoder Compatibility +Misc +Never Show Overlay +Hide Status Bar +Show Keyboard Button +Save Session Settings +Version %@-%@ +Unknown versino +Unknown commit +Disconnected (reason unknown) +Disconnected (code %d) +Decode %@ms Encode %@ms Network %@ms Bitrate %@Mbps %@ %@x%@ %@ %@ +Close +Hide Overlay +Copy +Paste +Sound: %@ +OFF +ON +Resolution +Bitrate +Auto +Switch Display +Constant FPS: %@ +Zoom: %@ +Disconnect +Pick your preference: +Choose... +Done +KEYS + +if [ "$missing" -ne 0 ]; then + exit 1 +fi + +echo "Localization resources contain required zh-Hans keys."