From 2f3e6a52a4eb4386c9a2c0fd63239a05fe608dfc Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:25:08 +0200 Subject: [PATCH 1/7] fix(presence): treat an already-applied activation policy as success setActivationPolicy returns false when the policy is already in effect. That made every launch log a restore failure and made switching between Dock Only and Dock and Menu Bar fail. Fixes #113 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++++ Sources/OpenWritr/OpenWritrApp.swift | 18 +++++++++++- Tests/OpenWritrTests/AppPresenceTests.swift | 32 +++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d067766..00ce57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to OpenWritr will be documented in this file. Each release's notes on GitHub are generated from its section here, and the broker publication handoff fails when the section for a tag is missing or empty. +## [1.7.3] — 2026-10-10 + +### Fixed +- Switching between Dock Only and Dock and Menu Bar no longer fails. macOS refuses to set an activation policy that is already in effect, and both Dock modes use the same one, so the change was rejected +- OpenWritr no longer logs "Failed to restore app presence mode menuBarOnly" on every launch; applying the policy the app already has is no longer treated as a failure + ## [1.7.2] — 2026-10-09 ### Fixed diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index 7e597e4..d41e865 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -74,9 +74,25 @@ protocol ApplicationPresenceControlling { @MainActor final class SystemApplicationPresenceController: ApplicationPresenceControlling { + /// `setActivationPolicy` returns false when the policy is already in effect, which is + /// the normal case at launch (an `LSUIElement` app starts as `.accessory`) and when + /// switching between the two Dock modes. That is not a failure. + static func applyPolicy( + _ policy: NSApplication.ActivationPolicy, + current: NSApplication.ActivationPolicy, + set: (NSApplication.ActivationPolicy) -> Bool + ) -> Bool { + current == policy || set(policy) + } + func apply(_ presence: AppPresence, activate: Bool) -> Bool { let policy: NSApplication.ActivationPolicy = presence.showsDock ? .regular : .accessory - guard NSApplication.shared.setActivationPolicy(policy) else { return false } + let application = NSApplication.shared + guard Self.applyPolicy( + policy, + current: application.activationPolicy(), + set: application.setActivationPolicy + ) else { return false } if activate, presence.showsDock { NSApplication.shared.activate(ignoringOtherApps: true) } diff --git a/Tests/OpenWritrTests/AppPresenceTests.swift b/Tests/OpenWritrTests/AppPresenceTests.swift index 3adfb4f..e04f47d 100644 --- a/Tests/OpenWritrTests/AppPresenceTests.swift +++ b/Tests/OpenWritrTests/AppPresenceTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @testable import OpenWritr @@ -65,6 +66,37 @@ struct AppPresenceTests { ]) } + @Test func policyAlreadyInEffectCountsAsSuccess() { + var attempts: [NSApplication.ActivationPolicy] = [] + for policy in [NSApplication.ActivationPolicy.accessory, .regular] { + let applied = SystemApplicationPresenceController.applyPolicy( + policy, current: policy + ) { requested in + attempts.append(requested) + return false + } + #expect(applied) + } + #expect(attempts.isEmpty) + } + + @Test func differentPolicyIsSetAndItsFailureReported() { + var attempts: [NSApplication.ActivationPolicy] = [] + let succeeded = SystemApplicationPresenceController.applyPolicy( + .regular, current: .accessory + ) { requested in + attempts.append(requested) + return true + } + let failed = SystemApplicationPresenceController.applyPolicy( + .accessory, current: .regular + ) { _ in false } + + #expect(succeeded) + #expect(attempts == [.regular]) + #expect(!failed) + } + private func makeDependencies() -> PresenceDependencies { let suiteName = "AppPresenceTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! From 87139320d435ca52cf0ad62298fa203484b09dd8 Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:46:06 +0200 Subject: [PATCH 2/7] feat(diagnostics): add a privacy-safe device summary to audio error logs Audio error logs became public issues with only an OSStatus, so the device details needed to diagnose them had to be requested afterwards. Append how the microphone was chosen, its transport, format, and whether another process uses it. Never log device names or UIDs. Refs #114, #119 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + Sources/OpenWritr/AudioDiagnostics.swift | 156 ++++++++++++++++++ Sources/OpenWritr/OpenWritrApp.swift | 29 +++- .../AppViewModelDictationFlowTests.swift | 6 +- .../AudioDiagnosticsTests.swift | 80 +++++++++ 5 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 Sources/OpenWritr/AudioDiagnostics.swift create mode 100644 Tests/OpenWritrTests/AudioDiagnosticsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ce57a..19879f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ broker publication handoff fails when the section for a tag is missing or empty. - Switching between Dock Only and Dock and Menu Bar no longer fails. macOS refuses to set an activation policy that is already in effect, and both Dock modes use the same one, so the change was rejected - OpenWritr no longer logs "Failed to restore app presence mode menuBarOnly" on every launch; applying the policy the app already has is no longer treated as a failure +### Changed +- Microphone error logs now include a privacy-safe device summary (how the microphone was chosen, transport such as Bluetooth or USB, channels, sample rate, whether another app is using it, and microphone permission) so Core Audio failures can be diagnosed without asking for details. Device names and identifiers are never logged + ## [1.7.2] — 2026-10-09 ### Fixed diff --git a/Sources/OpenWritr/AudioDiagnostics.swift b/Sources/OpenWritr/AudioDiagnostics.swift new file mode 100644 index 0000000..812aee9 --- /dev/null +++ b/Sources/OpenWritr/AudioDiagnostics.swift @@ -0,0 +1,156 @@ +@preconcurrency import AVFoundation +import CoreAudio +import Foundation + +/// A privacy-safe description of the microphone state, appended to audio error logs. +/// +/// Error logs are filed as public GitHub issues, so this never includes device names or UIDs, +/// which often contain a person's name. It keeps what is needed to tell device-specific +/// Core Audio failures apart: how the device was chosen, its transport, format, and whether +/// another process is already using it. +struct AudioDiagnostics: Equatable, Sendable { + enum Selection: String, Sendable { + case systemDefault = "system-default" + case explicit + } + + struct Device: Equatable, Sendable { + let isDefault: Bool + let transport: String + let isVirtualRoute: Bool + let inputChannels: Int + let sampleRate: Double? + let isAlive: Bool? + let isRunningElsewhere: Bool? + } + + let selection: Selection + let microphoneAccess: String + let inputDeviceCount: Int + /// `nil` when the device could not be resolved, for example if it just disappeared. + let device: Device? + + var summary: String { + var parts = [ + "selection=\(selection.rawValue)", + "access=\(microphoneAccess)", + "inputs=\(inputDeviceCount)", + ] + guard let device else { + parts.append("device=unresolved") + return parts.joined(separator: " ") + } + parts.append("default=\(device.isDefault)") + parts.append("transport=\(device.transport)") + parts.append("virtual=\(device.isVirtualRoute)") + parts.append("channels=\(device.inputChannels)") + parts.append("rate=\(device.sampleRate.map { String(Int($0.rounded())) } ?? "unknown")") + parts.append("alive=\(Self.describe(device.isAlive))") + parts.append("busy=\(Self.describe(device.isRunningElsewhere))") + return parts.joined(separator: " ") + } + + /// `selectedDeviceID` is the explicit user choice, or `nil` to follow System Default. + static func current(selectedDeviceID: AudioDeviceID?) -> AudioDiagnostics { + let devices = AudioEngine.availableInputDevices() + let defaultID = try? AudioEngine.currentSystemDefaultInputDeviceID() + let resolvedID = selectedDeviceID ?? defaultID + let device = resolvedID.flatMap { id in + devices.first { $0.id == id }.map { input in + Device( + isDefault: id == defaultID, + transport: transportName(of: id), + isVirtualRoute: input.isLikelyVirtualRoute, + inputChannels: inputChannelCount(of: id), + sampleRate: property(of: id, selector: kAudioDevicePropertyNominalSampleRate) as Float64?, + isAlive: (property(of: id, selector: kAudioDevicePropertyDeviceIsAlive) as UInt32?) + .map { $0 != 0 }, + isRunningElsewhere: ( + property(of: id, selector: kAudioDevicePropertyDeviceIsRunningSomewhere) as UInt32? + ).map { $0 != 0 } + ) + } + } + return AudioDiagnostics( + selection: selectedDeviceID == nil ? .systemDefault : .explicit, + microphoneAccess: accessName(AVCaptureDevice.authorizationStatus(for: .audio)), + inputDeviceCount: devices.count, + device: device + ) + } + + static func transportName(forCode code: UInt32) -> String { + switch code { + case kAudioDeviceTransportTypeBuiltIn: return "built-in" + case kAudioDeviceTransportTypeUSB: return "usb" + case kAudioDeviceTransportTypeBluetooth: return "bluetooth" + case kAudioDeviceTransportTypeBluetoothLE: return "bluetooth-le" + case kAudioDeviceTransportTypeAggregate: return "aggregate" + case kAudioDeviceTransportTypeVirtual: return "virtual" + case kAudioDeviceTransportTypeThunderbolt: return "thunderbolt" + case kAudioDeviceTransportTypeHDMI: return "hdmi" + case kAudioDeviceTransportTypeDisplayPort: return "displayport" + case kAudioDeviceTransportTypeAirPlay: return "airplay" + case kAudioDeviceTransportTypeFireWire: return "firewire" + case kAudioDeviceTransportTypePCI: return "pci" + case kAudioDeviceTransportTypeAutoAggregate: return "auto-aggregate" + case kAudioDeviceTransportTypeUnknown: return "unknown" + default: return "other" + } + } + + private static func describe(_ value: Bool?) -> String { + value.map { $0 ? "yes" : "no" } ?? "unknown" + } + + private static func accessName(_ status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: return "authorized" + case .denied: return "denied" + case .restricted: return "restricted" + case .notDetermined: return "not-determined" + @unknown default: return "unknown" + } + } + + private static func transportName(of deviceID: AudioDeviceID) -> String { + (property(of: deviceID, selector: kAudioDevicePropertyTransportType) as UInt32?) + .map(transportName(forCode:)) ?? "unknown" + } + + private static func property( + of deviceID: AudioDeviceID, + selector: AudioObjectPropertySelector + ) -> Value? where Value: BitwiseCopyable { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size = UInt32(MemoryLayout.size) + let buffer = UnsafeMutablePointer.allocate(capacity: 1) + defer { buffer.deallocate() } + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, buffer) == noErr else { + return nil + } + return buffer.pointee + } + + private static func inputChannelCount(of deviceID: AudioDeviceID) -> Int { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) == noErr, + size > 0 + else { return 0 } + let pointer = UnsafeMutablePointer.allocate(capacity: Int(size)) + defer { pointer.deallocate() } + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, pointer) == noErr else { + return 0 + } + return UnsafeMutableAudioBufferListPointer(pointer).reduce(0) { $0 + Int($1.mNumberChannels) } + } +} diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index d41e865..0852a36 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -162,6 +162,7 @@ final class AppViewModel { private let doneDisplayDuration: Duration private let transientErrorDisplayDuration: Duration private let errorLogger: any ErrorLogging + private let audioDiagnostics: (AudioDeviceID?) -> String private let applicationPresenceController: any ApplicationPresenceControlling private let appPresenceDefaults: UserDefaults private var didConfigure = false @@ -199,7 +200,10 @@ final class AppViewModel { transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration, applicationPresenceController: any ApplicationPresenceControlling = SystemApplicationPresenceController(), - appPresenceDefaults: UserDefaults = .standard + appPresenceDefaults: UserDefaults = .standard, + audioDiagnostics: @escaping (AudioDeviceID?) -> String = { + AudioDiagnostics.current(selectedDeviceID: $0).summary + } ) { appPresence = AppPresence.restored( from: appPresenceDefaults.string(forKey: "appPresence") @@ -210,6 +214,7 @@ final class AppViewModel { self.pasteManager = pasteManager self.overlayPanel = overlayPanel self.errorLogger = errorLogger + self.audioDiagnostics = audioDiagnostics self.doneDisplayDuration = doneDisplayDuration self.transientErrorDisplayDuration = transientErrorDisplayDuration self.applicationPresenceController = applicationPresenceController @@ -384,7 +389,7 @@ final class AppViewModel { state = .loading if case .failure(let error) = prepareAudioForStartup() { - errorLogger.logError("Audio initialization failed: \(error.localizedDescription)") + logAudioError("Audio initialization failed: \(error.localizedDescription)") presentInitializationError( errorPresentation(kind: .audio, title: "Microphone Initialization Failed", error: error) ) @@ -761,6 +766,12 @@ final class AppViewModel { UserDefaults.standard.set(value, forKey: key) } + /// Audio failures are filed as public issues, so they carry a privacy-safe device summary + /// (transport, format, selection mode) rather than a bare Core Audio status. + private func logAudioError(_ message: String) { + errorLogger.logError("\(message) [\(audioDiagnostics(selectedInputDeviceID))]") + } + func setAppPresence(_ presence: AppPresence) { guard presence != appPresence else { return } guard applicationPresenceController.apply(presence, activate: true) else { @@ -840,7 +851,7 @@ final class AppViewModel { state = .ready } case .failure(let error): - errorLogger.logError("Input device validation failed: \(error.localizedDescription)") + logAudioError("Input device validation failed: \(error.localizedDescription)") if device == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -855,7 +866,7 @@ final class AppViewModel { clearSelectedInputDevice() inputDeviceStatusMessage = "The previous macOS system input could not be restored." } - errorLogger.logError("Input device selection failed: \(error.localizedDescription)") + logAudioError("Input device selection failed: \(error.localizedDescription)") if device == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -895,7 +906,7 @@ final class AppViewModel { guard !didShutdown, isOperational else { return } if let operationID = captureOperationID, captureHandle == nil { - errorLogger.logError( + logAudioError( "Runtime audio failure invalidated pending capture: \(error.localizedDescription)" ) pendingStartTask?.cancel() @@ -914,7 +925,7 @@ final class AppViewModel { guard let operationID = captureOperationID, let handle = captureHandle else { - errorLogger.logError("Runtime audio failure: \(error.localizedDescription)") + logAudioError("Runtime audio failure: \(error.localizedDescription)") inputDeviceStatusMessage = "The microphone configuration failed: \(error.localizedDescription)" if selectedInputDeviceID == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) @@ -927,7 +938,7 @@ final class AppViewModel { return } - errorLogger.logError( + logAudioError( "Runtime audio failure invalidated capture generation \(handle.generation): \(error.localizedDescription)" ) activeProcessingTask?.cancel() @@ -966,7 +977,7 @@ final class AppViewModel { overlayPanel.dismiss() state = .ready case .failure(let error): - errorLogger.logError("Microphone retry validation failed: \(error.localizedDescription)") + logAudioError("Microphone retry validation failed: \(error.localizedDescription)") if selectedInputDeviceID == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -1403,7 +1414,7 @@ final class AppViewModel { !Task.isCancelled, case .preparingMicrophone = state else { return } - errorLogger.logError("Microphone start failed: \(error.localizedDescription)") + logAudioError("Microphone start failed: \(error.localizedDescription)") if selectedInputDeviceID == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { diff --git a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift index e49e1fe..9ea44cf 100644 --- a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift +++ b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift @@ -285,6 +285,7 @@ struct AppViewModelDictationFlowTests { #expect( dependencies.errorLogger.messages.contains { $0.hasPrefix("Runtime audio failure invalidated capture generation 1:") + && $0.hasSuffix(" [selection=explicit:42 synthetic]") } ) viewModel.retryMicrophone() @@ -473,7 +474,10 @@ private struct DictationDependencies { errorLogger: errorLogger, startsOperational: true, doneDisplayDuration: .milliseconds(1), - transientErrorDisplayDuration: transientErrorDisplayDuration + transientErrorDisplayDuration: transientErrorDisplayDuration, + audioDiagnostics: { selected in + "selection=\(selected.map { "explicit:\($0)" } ?? "system-default") synthetic" + } ) } } diff --git a/Tests/OpenWritrTests/AudioDiagnosticsTests.swift b/Tests/OpenWritrTests/AudioDiagnosticsTests.swift new file mode 100644 index 0000000..9105b30 --- /dev/null +++ b/Tests/OpenWritrTests/AudioDiagnosticsTests.swift @@ -0,0 +1,80 @@ +import CoreAudio +import Foundation +import Testing +@testable import OpenWritr + +@Suite("Audio diagnostics") +struct AudioDiagnosticsTests { + @Test func summaryDescribesAnExplicitBluetoothDevice() { + let diagnostics = AudioDiagnostics( + selection: .explicit, + microphoneAccess: "authorized", + inputDeviceCount: 3, + device: .init( + isDefault: false, + transport: "bluetooth", + isVirtualRoute: false, + inputChannels: 1, + sampleRate: 16_000, + isAlive: true, + isRunningElsewhere: true + ) + ) + + #expect(diagnostics.summary == "selection=explicit access=authorized inputs=3 default=false " + + "transport=bluetooth virtual=false channels=1 rate=16000 alive=yes busy=yes") + } + + @Test func summaryMarksUnknownValuesAndAnUnresolvedDevice() { + let unknown = AudioDiagnostics( + selection: .systemDefault, + microphoneAccess: "denied", + inputDeviceCount: 1, + device: .init( + isDefault: true, + transport: "unknown", + isVirtualRoute: true, + inputChannels: 0, + sampleRate: nil, + isAlive: nil, + isRunningElsewhere: nil + ) + ) + let unresolved = AudioDiagnostics( + selection: .systemDefault, + microphoneAccess: "authorized", + inputDeviceCount: 0, + device: nil + ) + + #expect(unknown.summary.contains("rate=unknown alive=unknown busy=unknown")) + #expect(unresolved.summary == "selection=system-default access=authorized inputs=0 device=unresolved") + } + + @Test func transportCodesMapToStableNames() { + let expected: [(UInt32, String)] = [ + (kAudioDeviceTransportTypeBuiltIn, "built-in"), + (kAudioDeviceTransportTypeUSB, "usb"), + (kAudioDeviceTransportTypeBluetooth, "bluetooth"), + (kAudioDeviceTransportTypeBluetoothLE, "bluetooth-le"), + (kAudioDeviceTransportTypeAggregate, "aggregate"), + (kAudioDeviceTransportTypeVirtual, "virtual"), + (kAudioDeviceTransportTypeUnknown, "unknown"), + (0x1234_5678, "other"), + ] + for (code, name) in expected { + #expect(AudioDiagnostics.transportName(forCode: code) == name) + } + } + + @Test func liveSnapshotNeverIncludesDeviceNamesOrUIDs() { + let devices = AudioEngine.availableInputDevices() + let summary = AudioDiagnostics.current(selectedDeviceID: devices.first?.id).summary + + #expect(summary.hasPrefix("selection=")) + for device in devices { + #expect(!summary.contains(device.name)) + #expect(!summary.contains(device.uid)) + } + } +} From 469ceaff83b8e28ffa33d65015b2aaa7042a1369 Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:08:03 +0200 Subject: [PATCH 3/7] feat(paste): add an experimental option to type text into remote desktop apps Windows App syncs the Mac clipboard to the remote PC only after its window loses focus, so an automatic paste inserts stale text. When the option is on and Windows App is frontmost, type the text as keyboard events instead of pasting, and leave the clipboard untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + README.md | 6 + Sources/OpenWritr/OpenWritrApp.swift | 21 +- Sources/OpenWritr/PasteManager.swift | 40 +++- Sources/OpenWritr/SettingsView.swift | 19 ++ Sources/OpenWritr/TextTyping.swift | 115 +++++++++++ Tests/OpenWritrTests/TextTypingTests.swift | 230 +++++++++++++++++++++ 7 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 Sources/OpenWritr/TextTyping.swift create mode 100644 Tests/OpenWritrTests/TextTypingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 19879f4..2f2e4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ broker publication handoff fails when the section for a tag is missing or empty. ### Changed - Microphone error logs now include a privacy-safe device summary (how the microphone was chosen, transport such as Bluetooth or USB, channels, sample rate, whether another app is using it, and microphone permission) so Core Audio failures can be diagnosed without asking for details. Device names and identifiers are never logged +### Added +- Experimental setting **Type Text in Remote Desktop Apps**. Windows App copies the Mac clipboard to the remote PC only after you leave its window, so an automatic paste could insert old text. With the setting on, and Windows App frontmost, OpenWritr types the text as keystrokes instead and leaves the clipboard untouched. Off by default + ## [1.7.2] — 2026-10-09 ### Fixed diff --git a/README.md b/README.md index 6526ef5..842202e 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ Clipboard-only works even with Auto-Paste off. Successful copying intentionally Normal Auto-Paste restores the original clipboard, including an empty clipboard, unless another application or the user has changed it. Output errors are shown explicitly; delayed restoration errors appear as a dismissible menu warning. With Auto-Paste off, standard recordings remain inside OpenWritr and are not copied automatically. +### Remote desktop apps (experimental) + +Windows App copies the Mac clipboard to the remote PC only after you leave its window, even with clipboard redirection set to Bidirectional. A paste into the remote session can therefore insert old text. Turn on **Type Text in Remote Desktop Apps (Experimental)** in Settings: while Windows App is frontmost, Auto-Paste then types the text as keystrokes instead of pasting, and your clipboard is not touched. It applies only to Windows App (`com.microsoft.rdc.macos`), only with Auto-Paste on, and not to clipboard-only recordings (Option), which still copy. + +The option is off by default and was verified only against a native Mac text view; whether Windows App and the remote PC accept the keystrokes unchanged depends on the remote keyboard layout. Test it in a harmless text field first. Typing needs the same Accessibility permission as pasting. + ## Install Download the latest signed app from [Releases](https://github.com/trsdn/OpenWritr/releases), unzip it, and move `OpenWritr.app` to `/Applications`. diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index 0852a36..9e4da83 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -128,6 +128,8 @@ final class AppViewModel { var debugModeEnabled: Bool = false var soundEnabled: Bool = true var autoPasteEnabled: Bool = true + /// Experimental: type instead of paste when a remote desktop app is frontmost. + var typeIntoRemoteDesktopsEnabled: Bool = false var launchAtLogin: Bool = false var appPresence: AppPresence var hotkeyChoice: HotkeyChoice = .fn @@ -484,6 +486,9 @@ final class AppViewModel { if defaults.object(forKey: "autoPasteEnabled") != nil { autoPasteEnabled = defaults.bool(forKey: "autoPasteEnabled") } + if defaults.object(forKey: "typeIntoRemoteDesktopsEnabled") != nil { + typeIntoRemoteDesktopsEnabled = defaults.bool(forKey: "typeIntoRemoteDesktopsEnabled") + } if let raw = defaults.string(forKey: "hotkeyChoice"), let choice = HotkeyChoice(rawValue: raw) { hotkeyChoice = choice @@ -1671,7 +1676,8 @@ final class AppViewModel { if pasteManager.outputText( text, destination: outputDestination, - autoPasteEnabled: autoPasteEnabled + autoPasteEnabled: autoPasteEnabled, + typeIntoRemoteDesktops: typeIntoRemoteDesktopsEnabled ) == .cancelled { if let (operationID, _) = captureOperation { clearCaptureOperation(ifCurrent: operationID) @@ -1723,6 +1729,19 @@ final class AppViewModel { ) return } + if pasteManager.lastError == .typingFailed { + presentRuntimeError( + AppErrorPresentation( + kind: .paste, + title: "Typing Failed", + message: pasteManager.lastError?.localizedDescription + ?? "OpenWritr could not type the transcript.", + recoverySuggestion: "The transcript is retained in OpenWritr. Check Accessibility access, then try again." + ), + overlayMessage: "Typing failed" + ) + return + } presentRuntimeError( AppErrorPresentation( kind: .paste, diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index 340253d..f5cce93 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -54,9 +54,16 @@ protocol TextPasting: AnyObject { func pasteText(_ text: String) -> PasteOutcome @discardableResult func copyText(_ text: String) -> PasteOutcome + /// Types `text` instead of pasting when the frontmost app is a remote desktop client, or + /// returns nil so the caller falls back to pasting. + func typeTextIfRemoteDesktop(_ text: String) -> PasteOutcome? func flushPendingRestore() } +extension TextPasting { + func typeTextIfRemoteDesktop(_ text: String) -> PasteOutcome? { nil } +} + enum PasteOutcome: Sendable, Equatable { case pasted case copied @@ -71,9 +78,12 @@ enum PasteManagerError: LocalizedError, Sendable, Equatable { case writeFailed case restoreFailed case rollbackFailed + case typingFailed var errorDescription: String? { switch self { + case .typingFailed: + return "OpenWritr could not type the text into the remote desktop app." case .unreadableRepresentation(let type): return "OpenWritr could not preserve clipboard content of type \(type)." case .preparationFailed: @@ -94,13 +104,18 @@ extension TextPasting { func outputText( _ text: String, destination: RecordingOutputDestination, - autoPasteEnabled: Bool + autoPasteEnabled: Bool, + typeIntoRemoteDesktops: Bool = false ) -> PasteOutcome { switch destination { case .clipboard: return copyText(text) case .standard: - return autoPasteEnabled ? pasteText(text) : .retained + guard autoPasteEnabled else { return .retained } + if typeIntoRemoteDesktops, let outcome = typeTextIfRemoteDesktop(text) { + return outcome + } + return pasteText(text) } } } @@ -211,17 +226,23 @@ final class PasteManager: TextPasting { private let commandPoster: any PasteCommandPosting private let restoreScheduler: any PasteRestoreScheduling private let errorLogger: any ErrorLogging + private let textTyper: any TextTyping + private let remoteDesktopDetector: any RemoteDesktopDetecting init( pasteboard: any PasteboardManaging = SystemPasteboard(), commandPoster: any PasteCommandPosting = SystemPasteCommandPoster(), restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler(), - errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "PasteManager") + errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "PasteManager"), + textTyper: any TextTyping = SystemTextTyper(), + remoteDesktopDetector: any RemoteDesktopDetecting = SystemRemoteDesktopDetector() ) { self.pasteboard = pasteboard self.commandPoster = commandPoster self.restoreScheduler = restoreScheduler self.errorLogger = errorLogger + self.textTyper = textTyper + self.remoteDesktopDetector = remoteDesktopDetector } @discardableResult @@ -234,6 +255,19 @@ final class PasteManager: TextPasting { writeText(text, destination: .clipboard) } + func typeTextIfRemoteDesktop(_ text: String) -> PasteOutcome? { + guard remoteDesktopDetector.isRemoteDesktopFrontmost() else { return nil } + + lastError = nil + // The clipboard is not used, but a restore left over from an earlier paste is still due. + flushPendingRestore() + guard textTyper.typeText(text) else { + recordError(.typingFailed, message: "Failed to type text into the remote desktop app") + return .cancelled + } + return .pasted + } + private func writeText(_ text: String, destination: RecordingOutputDestination) -> PasteOutcome { if hasUnreportedRestoreFailure { hasUnreportedRestoreFailure = false diff --git a/Sources/OpenWritr/SettingsView.swift b/Sources/OpenWritr/SettingsView.swift index 4da618b..77dbd7e 100644 --- a/Sources/OpenWritr/SettingsView.swift +++ b/Sources/OpenWritr/SettingsView.swift @@ -114,6 +114,25 @@ struct SettingsView: View { } )) + Toggle("Type Text in Remote Desktop Apps (Experimental)", isOn: Binding( + get: { viewModel.typeIntoRemoteDesktopsEnabled }, + set: { + viewModel.typeIntoRemoteDesktopsEnabled = $0 + viewModel.savePreference("typeIntoRemoteDesktopsEnabled", value: $0) + } + )) + .disabled(!viewModel.autoPasteEnabled) + + Text( + "Windows App copies your clipboard to the remote PC only after you leave its window, " + + "so a paste can insert old text. When Windows App is frontmost, OpenWritr types " + + "the text as keystrokes instead and leaves your clipboard untouched. " + + "Check the result in a harmless text field first." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Toggle("Sound Effects", isOn: Binding( get: { viewModel.soundEnabled }, set: { diff --git a/Sources/OpenWritr/TextTyping.swift b/Sources/OpenWritr/TextTyping.swift new file mode 100644 index 0000000..6dde159 --- /dev/null +++ b/Sources/OpenWritr/TextTyping.swift @@ -0,0 +1,115 @@ +import Cocoa +import CoreGraphics +import os.log + +private let typingLog = Logger(subsystem: "com.openwritr.app", category: "TextTyping") + +/// One step of typing text into another app. +enum TypingUnit: Equatable, Sendable { + case text(String) + case returnKey + case tabKey +} + +enum TextTypingPlan { + /// A keyboard event can carry only a short Unicode string, so longer text is split. The limit + /// is in UTF-16 units and chunks never split a character, so emoji and accents stay intact. + static let maximumChunkUTF16Units = 16 + + static func units(for text: String) -> [TypingUnit] { + var units: [TypingUnit] = [] + var chunk = "" + + func flush() { + if !chunk.isEmpty { + units.append(.text(chunk)) + chunk = "" + } + } + + for character in text { + // "\r\n" is a single Character in Swift. + if character == "\n" || character == "\r" || character == "\r\n" { + flush() + units.append(.returnKey) + } else if character == "\t" { + flush() + units.append(.tabKey) + } else { + if chunk.utf16.count + character.utf16.count > maximumChunkUTF16Units { + flush() + } + chunk.append(character) + } + } + flush() + return units + } +} + +@MainActor +protocol TextTyping { + /// Types `text` as keystrokes into the frontmost app. Returns false if it could not be sent. + func typeText(_ text: String) -> Bool +} + +@MainActor +protocol RemoteDesktopDetecting { + func isRemoteDesktopFrontmost() -> Bool +} + +/// Apps whose clipboard is synchronised with the remote machine only after leaving their window, +/// so a pasted clipboard value is not available remotely yet. Typing avoids the clipboard. +struct SystemRemoteDesktopDetector: RemoteDesktopDetecting { + nonisolated static let bundleIdentifiers: Set = [ + "com.microsoft.rdc.macos", + ] + + func isRemoteDesktopFrontmost() -> Bool { + NSWorkspace.shared.frontmostApplication?.bundleIdentifier + .map(Self.bundleIdentifiers.contains) ?? false + } +} + +struct SystemTextTyper: TextTyping { + private static let returnKeyCode: CGKeyCode = 0x24 + private static let tabKeyCode: CGKeyCode = 0x30 + + func typeText(_ text: String) -> Bool { + guard let source = CGEventSource(stateID: .hidSystemState) else { + typingLog.error("Could not create an event source for typing") + return false + } + for unit in TextTypingPlan.units(for: text) { + let sent: Bool + switch unit { + case .text(let string): + sent = post(keyCode: 0, unicode: Array(string.utf16), source: source) + case .returnKey: + sent = post(keyCode: Self.returnKeyCode, unicode: nil, source: source) + case .tabKey: + sent = post(keyCode: Self.tabKeyCode, unicode: nil, source: source) + } + guard sent else { + typingLog.error("Could not create a keyboard event for typing") + return false + } + } + return true + } + + private func post(keyCode: CGKeyCode, unicode: [UniChar]?, source: CGEventSource) -> Bool { + guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) + else { return false } + for event in [keyDown, keyUp] { + // The push-to-talk key may still be held; do not let its modifiers change the text. + event.flags = [] + if let unicode { + event.keyboardSetUnicodeString(stringLength: unicode.count, unicodeString: unicode) + } + event.post(tap: .cgAnnotatedSessionEventTap) + } + return true + } +} diff --git a/Tests/OpenWritrTests/TextTypingTests.swift b/Tests/OpenWritrTests/TextTypingTests.swift new file mode 100644 index 0000000..74bd00b --- /dev/null +++ b/Tests/OpenWritrTests/TextTypingTests.swift @@ -0,0 +1,230 @@ +import AppKit +import Testing +@testable import OpenWritr + +@Suite("Text typing plan") +struct TextTypingPlanTests { + @Test func plainTextIsOneChunk() { + #expect(TextTypingPlan.units(for: "Hello world.") == [.text("Hello world.")]) + #expect(TextTypingPlan.units(for: "").isEmpty) + } + + @Test func newlinesAndTabsBecomeKeyPresses() { + #expect(TextTypingPlan.units(for: "a\nb\r\nc\rd\te") == [ + .text("a"), .returnKey, .text("b"), .returnKey, .text("c"), .returnKey, + .text("d"), .tabKey, .text("e"), + ]) + #expect(TextTypingPlan.units(for: "\n\n") == [.returnKey, .returnKey]) + } + + @Test func longTextIsSplitWithoutLosingOrReorderingCharacters() { + let text = "Ein längerer Satz mit Umlauten: äöü ß, and some English words." + let units = TextTypingPlan.units(for: text) + + #expect(units.count > 1) + var rebuilt = "" + for unit in units { + guard case .text(let chunk) = unit else { + Issue.record("Unexpected key press in plain text") + return + } + #expect(chunk.utf16.count <= TextTypingPlan.maximumChunkUTF16Units) + rebuilt += chunk + } + #expect(rebuilt == text) + } + + @Test func chunksNeverSplitEmojiOrCombiningCharacters() { + let family = "👨‍👩‍👧‍👦" + let combining = "e\u{0301}" + let text = String(repeating: "x", count: 14) + family + combining + "tail" + let chunks = TextTypingPlan.units(for: text).compactMap { unit -> String? in + if case .text(let chunk) = unit { return chunk } + return nil + } + + #expect(chunks.joined() == text) + #expect(chunks.contains { $0.contains(family) }) + #expect(chunks.contains { $0.contains(combining) }) + for chunk in chunks { + #expect(chunk.utf16.count <= TextTypingPlan.maximumChunkUTF16Units + || chunk.count == 1) + } + } + + @Test func onlyTheWindowsAppIsTreatedAsRemoteDesktop() { + #expect(SystemRemoteDesktopDetector.bundleIdentifiers == ["com.microsoft.rdc.macos"]) + } +} + +@MainActor +@Suite("Typing into remote desktop apps") +struct RemoteDesktopTypingTests { + @Test func typesWhenARemoteDesktopIsFrontmostWithoutTouchingTheClipboard() { + let setup = makeSetup(remote: true) + + #expect(setup.manager.typeTextIfRemoteDesktop("Synthetic text.") == .pasted) + + #expect(setup.typer.typedTexts == ["Synthetic text."]) + #expect(setup.poster.postCount == 0) + #expect(setup.pasteboard.changeCount == setup.initialChangeCount) + #expect(setup.manager.lastError == nil) + } + + @Test func fallsBackToPastingWhenAnotherAppIsFrontmost() { + let setup = makeSetup(remote: false) + + #expect(setup.manager.typeTextIfRemoteDesktop("Synthetic text.") == nil) + #expect(setup.typer.typedTexts.isEmpty) + + #expect(setup.manager.outputText( + "Synthetic text.", + destination: .standard, + autoPasteEnabled: true, + typeIntoRemoteDesktops: true + ) == .pasted) + #expect(setup.poster.postCount == 1) + #expect(setup.typer.typedTexts.isEmpty) + } + + @Test func failureCancelsWithATypingErrorAndKeepsTheClipboard() { + let setup = makeSetup(remote: true) + setup.typer.succeeds = false + + #expect(setup.manager.typeTextIfRemoteDesktop("Synthetic text.") == .cancelled) + + #expect(setup.manager.lastError == .typingFailed) + #expect(setup.poster.postCount == 0) + #expect(setup.pasteboard.changeCount == setup.initialChangeCount) + } + + @Test(arguments: [ + (RecordingOutputDestination.standard, true, true, "typed"), + (.standard, true, false, "pasted"), + (.standard, false, true, "retained"), + (.clipboard, true, true, "copied"), + ]) + func outputRoutingRespectsTheOptionAndDestination( + destination: RecordingOutputDestination, + autoPaste: Bool, + typeOption: Bool, + expected: String + ) { + let setup = makeSetup(remote: true) + + let outcome = setup.manager.outputText( + "Synthetic text.", + destination: destination, + autoPasteEnabled: autoPaste, + typeIntoRemoteDesktops: typeOption + ) + + switch expected { + case "typed": + #expect(outcome == .pasted) + #expect(setup.typer.typedTexts == ["Synthetic text."]) + #expect(setup.poster.postCount == 0) + case "pasted": + #expect(outcome == .pasted) + #expect(setup.typer.typedTexts.isEmpty) + #expect(setup.poster.postCount == 1) + case "retained": + #expect(outcome == .retained) + #expect(setup.typer.typedTexts.isEmpty) + #expect(setup.poster.postCount == 0) + default: + #expect(outcome == .copied) + #expect(setup.typer.typedTexts.isEmpty) + #expect(setup.poster.postCount == 0) + } + } + + @Test func pendingClipboardRestoreIsFlushedBeforeTyping() { + let setup = makeSetup(remote: false) + setup.pasteboard.clearContents() + #expect(setup.manager.pasteText("Earlier paste.") == .pasted) + #expect(setup.pasteboard.string(forType: .string) == "Earlier paste.") + + setup.detector.isRemote = true + #expect(setup.manager.typeTextIfRemoteDesktop("Synthetic text.") == .pasted) + + #expect(setup.pasteboard.string(forType: .string) != "Earlier paste.") + } + + private func makeSetup(remote: Bool) -> TypingSetup { + let pasteboard = NSPasteboard.withUniqueName() + pasteboard.clearContents() + pasteboard.setString("Original clipboard", forType: .string) + let typer = RecordingTextTyper() + let detector = StubRemoteDesktopDetector(isRemote: remote) + let poster = CountingPasteCommandPoster() + let manager = PasteManager( + pasteboard: SystemPasteboard(pasteboard), + commandPoster: poster, + restoreScheduler: NeverRunsRestoreScheduler(), + errorLogger: SilentErrorLogger(), + textTyper: typer, + remoteDesktopDetector: detector + ) + return TypingSetup( + manager: manager, + pasteboard: pasteboard, + typer: typer, + detector: detector, + poster: poster, + initialChangeCount: pasteboard.changeCount + ) + } +} + +@MainActor +private struct TypingSetup { + let manager: PasteManager + let pasteboard: NSPasteboard + let typer: RecordingTextTyper + let detector: StubRemoteDesktopDetector + let poster: CountingPasteCommandPoster + let initialChangeCount: Int +} + +@MainActor +private final class RecordingTextTyper: TextTyping { + var succeeds = true + private(set) var typedTexts: [String] = [] + + func typeText(_ text: String) -> Bool { + guard succeeds else { return false } + typedTexts.append(text) + return true + } +} + +@MainActor +private final class StubRemoteDesktopDetector: RemoteDesktopDetecting { + var isRemote: Bool + + init(isRemote: Bool) { + self.isRemote = isRemote + } + + func isRemoteDesktopFrontmost() -> Bool { isRemote } +} + +@MainActor +private final class CountingPasteCommandPoster: PasteCommandPosting { + private(set) var postCount = 0 + + func postPasteCommand() { + postCount += 1 + } +} + +@MainActor +private struct NeverRunsRestoreScheduler: PasteRestoreScheduling { + func scheduleRestore(_ action: @escaping @MainActor () -> Void) {} +} + +@MainActor +private struct SilentErrorLogger: ErrorLogging { + func logError(_ message: String) {} +} From af926c9525d03097afc96450b17e6086a31de5ef Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:11:06 +0200 Subject: [PATCH 4/7] fix(paste): type with real key codes instead of Unicode text Windows App reads the physical key code of a keyboard event and ignores its Unicode string, so the first version typed only a. Map each character to the key that produces it on the current keyboard layout and send Shift as a real modifier event. Skip and count characters without a plain key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 6 +- Sources/OpenWritr/SettingsView.swift | 4 +- Sources/OpenWritr/TextTyping.swift | 255 ++++++++++++++++----- Tests/OpenWritrTests/TextTypingTests.swift | 139 +++++++---- 5 files changed, 302 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f2e4d7..e31087e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ broker publication handoff fails when the section for a tag is missing or empty. - Microphone error logs now include a privacy-safe device summary (how the microphone was chosen, transport such as Bluetooth or USB, channels, sample rate, whether another app is using it, and microphone permission) so Core Audio failures can be diagnosed without asking for details. Device names and identifiers are never logged ### Added -- Experimental setting **Type Text in Remote Desktop Apps**. Windows App copies the Mac clipboard to the remote PC only after you leave its window, so an automatic paste could insert old text. With the setting on, and Windows App frontmost, OpenWritr types the text as keystrokes instead and leaves the clipboard untouched. Off by default +- Experimental setting **Type Text in Remote Desktop Apps**. Windows App copies the Mac clipboard to the remote PC only after you leave its window, so an automatic paste could insert old text. With the setting on, and Windows App frontmost, OpenWritr types the text key by key using your current Mac keyboard layout instead and leaves the clipboard untouched. The remote PC needs the same layout; characters without a plain key (emoji, Option-layer symbols) are skipped. Off by default ## [1.7.2] — 2026-10-09 diff --git a/README.md b/README.md index 842202e..efa3bdf 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,11 @@ Normal Auto-Paste restores the original clipboard, including an empty clipboard, ### Remote desktop apps (experimental) -Windows App copies the Mac clipboard to the remote PC only after you leave its window, even with clipboard redirection set to Bidirectional. A paste into the remote session can therefore insert old text. Turn on **Type Text in Remote Desktop Apps (Experimental)** in Settings: while Windows App is frontmost, Auto-Paste then types the text as keystrokes instead of pasting, and your clipboard is not touched. It applies only to Windows App (`com.microsoft.rdc.macos`), only with Auto-Paste on, and not to clipboard-only recordings (Option), which still copy. +Windows App copies the Mac clipboard to the remote PC only after you leave its window, even with clipboard redirection set to Bidirectional. A paste into the remote session can therefore insert old text. Turn on **Type Text in Remote Desktop Apps (Experimental)** in Settings: while Windows App is frontmost, Auto-Paste then types the text key by key instead of pasting, and your clipboard is not touched. It applies only to Windows App (`com.microsoft.rdc.macos`), only with Auto-Paste on, and not to clipboard-only recordings (Option), which still copy. -The option is off by default and was verified only against a native Mac text view; whether Windows App and the remote PC accept the keystrokes unchanged depends on the remote keyboard layout. Test it in a harmless text field first. Typing needs the same Accessibility permission as pasting. +Windows App sends physical key codes and ignores the Unicode text of a key event, so OpenWritr presses the keys that produce each character on your **current Mac keyboard layout**. The remote PC must use the same keyboard layout, otherwise accents and symbols come out wrong. Only characters on the two base layers (no modifier, Shift) are typed: typographic quotes, dashes, and ellipses are replaced by their plain equivalents, and characters that need Option (such as `@`, `€`, or brackets on a German layout), dead keys, or emoji have no equivalent on the remote PC's layout and are skipped. The number of skipped characters is logged; the full text stays available in OpenWritr. + +The option is off by default and has not been verified against a real remote session by the author. Test it in a harmless text field first. Typing needs the same Accessibility permission as pasting. ## Install diff --git a/Sources/OpenWritr/SettingsView.swift b/Sources/OpenWritr/SettingsView.swift index 77dbd7e..951b267 100644 --- a/Sources/OpenWritr/SettingsView.swift +++ b/Sources/OpenWritr/SettingsView.swift @@ -126,8 +126,8 @@ struct SettingsView: View { Text( "Windows App copies your clipboard to the remote PC only after you leave its window, " + "so a paste can insert old text. When Windows App is frontmost, OpenWritr types " - + "the text as keystrokes instead and leaves your clipboard untouched. " - + "Check the result in a harmless text field first." + + "the text key by key instead and leaves your clipboard untouched. The remote PC " + + "needs the same keyboard layout as this Mac. Emoji and symbols without a key are skipped." ) .font(.caption) .foregroundStyle(.secondary) diff --git a/Sources/OpenWritr/TextTyping.swift b/Sources/OpenWritr/TextTyping.swift index 6dde159..27b630e 100644 --- a/Sources/OpenWritr/TextTyping.swift +++ b/Sources/OpenWritr/TextTyping.swift @@ -1,55 +1,162 @@ +import Carbon.HIToolbox import Cocoa import CoreGraphics import os.log private let typingLog = Logger(subsystem: "com.openwritr.app", category: "TextTyping") -/// One step of typing text into another app. -enum TypingUnit: Equatable, Sendable { - case text(String) - case returnKey - case tabKey +/// A physical key on the Mac keyboard, optionally with Shift held. +struct KeyStroke: Equatable, Sendable { + let keyCode: CGKeyCode + let shift: Bool } -enum TextTypingPlan { - /// A keyboard event can carry only a short Unicode string, so longer text is split. The limit - /// is in UTF-16 units and chunks never split a character, so emoji and accents stay intact. - static let maximumChunkUTF16Units = 16 +struct TypingPlan: Equatable, Sendable { + var strokes: [KeyStroke] + /// Characters that no key of the current layout produces, so they cannot be typed. + var skippedCharacters: Int +} + +enum TextTypingPlanner { + static let returnStroke = KeyStroke(keyCode: 0x24, shift: false) + static let tabStroke = KeyStroke(keyCode: 0x30, shift: false) - static func units(for text: String) -> [TypingUnit] { - var units: [TypingUnit] = [] - var chunk = "" + /// Turns `text` into key strokes using `layout`, which maps each typeable character to the + /// key that produces it. Remote desktop clients send physical key codes, not Unicode text, so + /// only characters on the keyboard can be typed. Typographic characters are replaced by their + /// plain equivalents; anything else is skipped and counted. + static func plan(for text: String, layout: [Character: KeyStroke]) -> TypingPlan { + var strokes: [KeyStroke] = [] + var skipped = 0 - func flush() { - if !chunk.isEmpty { - units.append(.text(chunk)) - chunk = "" + for character in text.precomposedStringWithCanonicalMapping { + switch character { + case "\n", "\r", "\r\n": + strokes.append(returnStroke) + case "\t": + strokes.append(tabStroke) + default: + if let stroke = layout[character] { + strokes.append(stroke) + } else if let replacement = substitute(for: character), + replacement.allSatisfy({ layout[$0] != nil }) { + strokes.append(contentsOf: replacement.compactMap { layout[$0] }) + } else { + skipped += 1 + } } } + return TypingPlan(strokes: strokes, skippedCharacters: skipped) + } + + static func substitute(for character: Character) -> String? { + switch character { + case "\u{201C}", "\u{201D}", "\u{201E}", "\u{201F}", "\u{00AB}", "\u{00BB}": return "\"" + case "\u{2018}", "\u{2019}", "\u{201A}", "\u{201B}": return "'" + case "\u{2013}", "\u{2014}", "\u{2212}": return "-" + case "\u{2026}": return "..." + case "\u{00A0}", "\u{202F}", "\u{2009}": return " " + default: return nil + } + } +} + +/// Text Input Source Services must be called on the main thread, so this is main-actor isolated. +@MainActor +enum SystemKeyboardLayout { + /// Maps each character the current keyboard layout can type without dead keys to the key + /// (with or without Shift) that produces it. Unshifted keys win over shifted ones. + static func currentCharacterMap() -> [Character: KeyStroke]? { + guard let layoutData = unicodeLayoutData(), let bytes = CFDataGetBytePtr(layoutData) else { + return nil + } + let keyboardType = UInt32(LMGetKbdType()) - for character in text { - // "\r\n" is a single Character in Swift. - if character == "\n" || character == "\r" || character == "\r\n" { - flush() - units.append(.returnKey) - } else if character == "\t" { - flush() - units.append(.tabKey) - } else { - if chunk.utf16.count + character.utf16.count > maximumChunkUTF16Units { - flush() + return bytes.withMemoryRebound(to: UCKeyboardLayout.self, capacity: 1) { layout in + var map: [Character: KeyStroke] = [:] + for shift in [false, true] { + let modifiers: UInt32 = shift ? UInt32(shiftKey >> 8) & 0xFF : 0 + for keyCode in 0..<128 { + guard let character = translate( + layout: layout, + keyCode: UInt16(keyCode), + modifiers: modifiers, + keyboardType: keyboardType + ) else { continue } + if map[character] == nil { + map[character] = KeyStroke(keyCode: CGKeyCode(keyCode), shift: shift) + } } - chunk.append(character) } + return map + } + } + + /// The character the current layout produces for a key press, used to verify the map. + static func character(forKeyCode keyCode: CGKeyCode, shift: Bool) -> Character? { + guard let layoutData = unicodeLayoutData(), let bytes = CFDataGetBytePtr(layoutData) else { + return nil + } + return bytes.withMemoryRebound(to: UCKeyboardLayout.self, capacity: 1) { layout in + translate( + layout: layout, + keyCode: UInt16(keyCode), + modifiers: shift ? UInt32(shiftKey >> 8) & 0xFF : 0, + keyboardType: UInt32(LMGetKbdType()) + ) + } + } + + private static func unicodeLayoutData() -> CFData? { + for source in [ + TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(), + ] { + guard let source, + let pointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) + else { continue } + return Unmanaged.fromOpaque(pointer).takeUnretainedValue() } - flush() - return units + return nil + } + + /// The single printable character a key produces, or nil for dead keys, function keys and + /// control characters. + private static func translate( + layout: UnsafePointer, + keyCode: UInt16, + modifiers: UInt32, + keyboardType: UInt32 + ) -> Character? { + var deadKeyState: UInt32 = 0 + var length = 0 + var characters = [UniChar](repeating: 0, count: 4) + let status = UCKeyTranslate( + layout, + keyCode, + UInt16(kUCKeyActionDown), + modifiers, + keyboardType, + 0, + &deadKeyState, + characters.count, + &length, + &characters + ) + guard status == noErr, deadKeyState == 0, length > 0 else { return nil } + let string = String(utf16CodeUnits: characters, count: length) + guard string.count == 1, let character = string.first, + let scalar = string.unicodeScalars.first, + scalar.value >= 0x20, scalar.value != 0x7F, + !(0xF700...0xF8FF).contains(scalar.value) + else { return nil } + return character } } @MainActor protocol TextTyping { - /// Types `text` as keystrokes into the frontmost app. Returns false if it could not be sent. + /// Types `text` as key strokes into the frontmost app. Returns false if nothing could be sent. func typeText(_ text: String) -> Bool } @@ -71,45 +178,75 @@ struct SystemRemoteDesktopDetector: RemoteDesktopDetecting { } } +private struct EventBatch: @unchecked Sendable { + let events: [CGEvent] +} + struct SystemTextTyper: TextTyping { - private static let returnKeyCode: CGKeyCode = 0x24 - private static let tabKeyCode: CGKeyCode = 0x30 + private static let shiftKeyCode: CGKeyCode = 0x38 + /// Remote desktop clients can drop key events that arrive faster than they are forwarded. + private nonisolated static let eventSpacing: useconds_t = 3_000 + private nonisolated static let queue = DispatchQueue(label: "com.openwritr.text-typing") func typeText(_ text: String) -> Bool { - guard let source = CGEventSource(stateID: .hidSystemState) else { - typingLog.error("Could not create an event source for typing") + guard let layout = SystemKeyboardLayout.currentCharacterMap() else { + typingLog.error("Could not read the current keyboard layout") return false } - for unit in TextTypingPlan.units(for: text) { - let sent: Bool - switch unit { - case .text(let string): - sent = post(keyCode: 0, unicode: Array(string.utf16), source: source) - case .returnKey: - sent = post(keyCode: Self.returnKeyCode, unicode: nil, source: source) - case .tabKey: - sent = post(keyCode: Self.tabKeyCode, unicode: nil, source: source) - } - guard sent else { - typingLog.error("Could not create a keyboard event for typing") - return false + let plan = TextTypingPlanner.plan(for: text, layout: layout) + if plan.skippedCharacters > 0 { + typingLog.notice("Skipped \(plan.skippedCharacters) characters that no key can type") + } + if plan.strokes.isEmpty { return text.isEmpty } + + guard let source = CGEventSource(stateID: .hidSystemState), + let events = Self.events(for: plan.strokes, source: source) + else { + typingLog.error("Could not create keyboard events for typing") + return false + } + + // Posted off the main thread, in order, with a short pause between events. + let batch = EventBatch(events: events) + Self.queue.async { + for event in batch.events { + event.post(tap: .cgAnnotatedSessionEventTap) + usleep(Self.eventSpacing) } } return true } - private func post(keyCode: CGKeyCode, unicode: [UniChar]?, source: CGEventSource) -> Bool { - guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) - else { return false } - for event in [keyDown, keyUp] { - // The push-to-talk key may still be held; do not let its modifiers change the text. - event.flags = [] - if let unicode { - event.keyboardSetUnicodeString(stringLength: unicode.count, unicodeString: unicode) + private static func events(for strokes: [KeyStroke], source: CGEventSource) -> [CGEvent]? { + var events: [CGEvent] = [] + for stroke in strokes { + let flags: CGEventFlags = stroke.shift ? .maskShift : [] + if stroke.shift { + guard let shiftDown = modifierEvent(source: source, down: true) else { return nil } + events.append(shiftDown) + } + guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: stroke.keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: stroke.keyCode, keyDown: false) + else { return nil } + keyDown.flags = flags + keyUp.flags = flags + events.append(keyDown) + events.append(keyUp) + if stroke.shift { + guard let shiftUp = modifierEvent(source: source, down: false) else { return nil } + events.append(shiftUp) } - event.post(tap: .cgAnnotatedSessionEventTap) } - return true + return events + } + + /// Shift is sent as a real modifier key event, because a remote desktop client tracks modifier + /// changes rather than trusting the flags of a key event. + private static func modifierEvent(source: CGEventSource, down: Bool) -> CGEvent? { + guard let event = CGEvent(source: source) else { return nil } + event.type = .flagsChanged + event.setIntegerValueField(.keyboardEventKeycode, value: Int64(shiftKeyCode)) + event.flags = down ? .maskShift : [] + return event } } diff --git a/Tests/OpenWritrTests/TextTypingTests.swift b/Tests/OpenWritrTests/TextTypingTests.swift index 74bd00b..d99da7e 100644 --- a/Tests/OpenWritrTests/TextTypingTests.swift +++ b/Tests/OpenWritrTests/TextTypingTests.swift @@ -4,52 +4,76 @@ import Testing @Suite("Text typing plan") struct TextTypingPlanTests { - @Test func plainTextIsOneChunk() { - #expect(TextTypingPlan.units(for: "Hello world.") == [.text("Hello world.")]) - #expect(TextTypingPlan.units(for: "").isEmpty) + private let layout: [Character: KeyStroke] = { + var map: [Character: KeyStroke] = [:] + map["a"] = KeyStroke(keyCode: 0, shift: false) + map["A"] = KeyStroke(keyCode: 0, shift: true) + map["b"] = KeyStroke(keyCode: 11, shift: false) + map["ä"] = KeyStroke(keyCode: 39, shift: false) + map[" "] = KeyStroke(keyCode: 49, shift: false) + map["\""] = KeyStroke(keyCode: 19, shift: true) + map["'"] = KeyStroke(keyCode: 42, shift: true) + map["-"] = KeyStroke(keyCode: 44, shift: false) + map["."] = KeyStroke(keyCode: 47, shift: false) + return map + }() + + @Test func charactersMapToTheirKeysInOrder() { + let plan = TextTypingPlanner.plan(for: "aAb ä", layout: layout) + + #expect(plan.strokes == [ + KeyStroke(keyCode: 0, shift: false), + KeyStroke(keyCode: 0, shift: true), + KeyStroke(keyCode: 11, shift: false), + KeyStroke(keyCode: 49, shift: false), + KeyStroke(keyCode: 39, shift: false), + ]) + #expect(plan.skippedCharacters == 0) } @Test func newlinesAndTabsBecomeKeyPresses() { - #expect(TextTypingPlan.units(for: "a\nb\r\nc\rd\te") == [ - .text("a"), .returnKey, .text("b"), .returnKey, .text("c"), .returnKey, - .text("d"), .tabKey, .text("e"), - ]) - #expect(TextTypingPlan.units(for: "\n\n") == [.returnKey, .returnKey]) - } - - @Test func longTextIsSplitWithoutLosingOrReorderingCharacters() { - let text = "Ein längerer Satz mit Umlauten: äöü ß, and some English words." - let units = TextTypingPlan.units(for: text) - - #expect(units.count > 1) - var rebuilt = "" - for unit in units { - guard case .text(let chunk) = unit else { - Issue.record("Unexpected key press in plain text") - return - } - #expect(chunk.utf16.count <= TextTypingPlan.maximumChunkUTF16Units) - rebuilt += chunk - } - #expect(rebuilt == text) + let plan = TextTypingPlanner.plan(for: "a\nb\r\nc\rd\te", layout: layout) + + #expect(plan.strokes.filter { $0 == TextTypingPlanner.returnStroke }.count == 3) + #expect(plan.strokes.filter { $0 == TextTypingPlanner.tabStroke }.count == 1) + #expect(plan.skippedCharacters == 3, "c, d and e are not in the synthetic layout") } - @Test func chunksNeverSplitEmojiOrCombiningCharacters() { - let family = "👨‍👩‍👧‍👦" - let combining = "e\u{0301}" - let text = String(repeating: "x", count: 14) + family + combining + "tail" - let chunks = TextTypingPlan.units(for: text).compactMap { unit -> String? in - if case .text(let chunk) = unit { return chunk } - return nil - } + @Test func typographicCharactersAreReplacedByPlainOnes() { + let plan = TextTypingPlanner.plan(for: "\u{201E}a\u{201C} \u{2013} \u{2019}\u{2026}", layout: layout) + let quote = layout["\""]! + let apostrophe = layout["'"]! + let dash = layout["-"]! + let dot = layout["."]! + let space = layout[" "]! + let a = layout["a"]! + + #expect(plan.strokes == [quote, a, quote, space, dash, space, apostrophe, dot, dot, dot]) + #expect(plan.skippedCharacters == 0) + } - #expect(chunks.joined() == text) - #expect(chunks.contains { $0.contains(family) }) - #expect(chunks.contains { $0.contains(combining) }) - for chunk in chunks { - #expect(chunk.utf16.count <= TextTypingPlan.maximumChunkUTF16Units - || chunk.count == 1) - } + @Test func untypeableCharactersAreSkippedAndCounted() { + let plan = TextTypingPlanner.plan(for: "a😀b€", layout: layout) + + #expect(plan.strokes == [layout["a"]!, layout["b"]!]) + #expect(plan.skippedCharacters == 2) + } + + @Test func replacementIsSkippedWhenTheLayoutLacksItsCharacters() { + var small = layout + small["\""] = nil + + let plan = TextTypingPlanner.plan(for: "\u{201C}", layout: small) + + #expect(plan.strokes.isEmpty) + #expect(plan.skippedCharacters == 1) + } + + @Test func decomposedAccentsAreComposedBeforeMapping() { + let plan = TextTypingPlanner.plan(for: "a\u{0308}", layout: layout) + + #expect(plan.strokes == [layout["ä"]!]) + #expect(plan.skippedCharacters == 0) } @Test func onlyTheWindowsAppIsTreatedAsRemoteDesktop() { @@ -57,6 +81,41 @@ struct TextTypingPlanTests { } } +@MainActor +@Suite("System keyboard layout") +struct SystemKeyboardLayoutTests { + @Test func everyMappedCharacterRoundTripsThroughTheRealLayout() throws { + let map = try #require(SystemKeyboardLayout.currentCharacterMap()) + + #expect(map.count > 40) + for (character, stroke) in map { + #expect( + SystemKeyboardLayout.character(forKeyCode: stroke.keyCode, shift: stroke.shift) == character, + "Key \(stroke.keyCode) shift \(stroke.shift) does not type \(character)" + ) + } + } + + @Test func lettersUseTheUnshiftedKeyAndCapitalsAddShift() throws { + let map = try #require(SystemKeyboardLayout.currentCharacterMap()) + let lower = try #require(map["e"]) + let upper = try #require(map["E"]) + + #expect(!lower.shift) + #expect(upper.shift) + #expect(lower.keyCode == upper.keyCode) + } + + @Test func controlCharactersAreNeverMapped() throws { + let map = try #require(SystemKeyboardLayout.currentCharacterMap()) + + for character in map.keys { + let value = try #require(character.unicodeScalars.first?.value) + #expect(value >= 0x20 && value != 0x7F) + } + } +} + @MainActor @Suite("Typing into remote desktop apps") struct RemoteDesktopTypingTests { From ecbd9d1d9b03722976771240c14af427cad3df1a Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:21:16 +0200 Subject: [PATCH 5/7] docs(paste): record verification of typing on a real Cloud PC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efa3bdf..eed2b2a 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Windows App copies the Mac clipboard to the remote PC only after you leave its w Windows App sends physical key codes and ignores the Unicode text of a key event, so OpenWritr presses the keys that produce each character on your **current Mac keyboard layout**. The remote PC must use the same keyboard layout, otherwise accents and symbols come out wrong. Only characters on the two base layers (no modifier, Shift) are typed: typographic quotes, dashes, and ellipses are replaced by their plain equivalents, and characters that need Option (such as `@`, `€`, or brackets on a German layout), dead keys, or emoji have no equivalent on the remote PC's layout and are skipped. The number of skipped characters is logged; the full text stays available in OpenWritr. -The option is off by default and has not been verified against a real remote session by the author. Test it in a harmless text field first. Typing needs the same Accessibility permission as pasting. +The option is off by default. It was verified by the maintainer against a real Windows 365 Cloud PC session through Windows App 11.4.0 with matching German keyboard layouts; other remote layouts, clients, and character sets have not been tested. Test it in a harmless text field first. Typing needs the same Accessibility permission as pasting. ## Install From d62fd22fd36225449827bde816d88b1f09513b08 Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:01:26 +0200 Subject: [PATCH 6/7] chore(release): bump version to 1.7.3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- Info.plist | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e31087e..35cc20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to OpenWritr will be documented in this file. Each release's notes on GitHub are generated from its section here, and the broker publication handoff fails when the section for a tag is missing or empty. -## [1.7.3] — 2026-10-10 +## [1.7.3] — 2026-10-13 ### Fixed - Switching between Dock Only and Dock and Menu Bar no longer fails. macOS refuses to set an activation policy that is already in effect, and both Dock modes use the same one, so the change was rejected diff --git a/Info.plist b/Info.plist index 7f26e6a..878953b 100644 --- a/Info.plist +++ b/Info.plist @@ -7,9 +7,9 @@ CFBundleIdentifier com.openwritr.app CFBundleVersion - 1.7.2 + 1.7.3 CFBundleShortVersionString - 1.7.2 + 1.7.3 NSHumanReadableCopyright Copyright © 2026 Torsten Mahr. Released under the MIT License. OpenWritrDescription From 5df32065029d50104b459e8529e809daa5e69eb4 Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:15:19 +0200 Subject: [PATCH 7/7] fix(paste): stop typing when focus leaves the remote desktop app Typing runs in the background and can take seconds. Check before every key that the app that was frontmost is still frontmost, so the rest of a transcript is never typed into a different app, and make the typing error advice mention the keyboard layout as well as Accessibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- Sources/OpenWritr/OpenWritrApp.swift | 2 +- Sources/OpenWritr/TextTyping.swift | 62 ++++++++++++++++++---- Tests/OpenWritrTests/TextTypingTests.swift | 48 +++++++++++++++++ 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index eed2b2a..ff875ec 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Normal Auto-Paste restores the original clipboard, including an empty clipboard, Windows App copies the Mac clipboard to the remote PC only after you leave its window, even with clipboard redirection set to Bidirectional. A paste into the remote session can therefore insert old text. Turn on **Type Text in Remote Desktop Apps (Experimental)** in Settings: while Windows App is frontmost, Auto-Paste then types the text key by key instead of pasting, and your clipboard is not touched. It applies only to Windows App (`com.microsoft.rdc.macos`), only with Auto-Paste on, and not to clipboard-only recordings (Option), which still copy. -Windows App sends physical key codes and ignores the Unicode text of a key event, so OpenWritr presses the keys that produce each character on your **current Mac keyboard layout**. The remote PC must use the same keyboard layout, otherwise accents and symbols come out wrong. Only characters on the two base layers (no modifier, Shift) are typed: typographic quotes, dashes, and ellipses are replaced by their plain equivalents, and characters that need Option (such as `@`, `€`, or brackets on a German layout), dead keys, or emoji have no equivalent on the remote PC's layout and are skipped. The number of skipped characters is logged; the full text stays available in OpenWritr. +Windows App sends physical key codes and ignores the Unicode text of a key event, so OpenWritr presses the keys that produce each character on your **current Mac keyboard layout**. The remote PC must use the same keyboard layout, otherwise accents and symbols come out wrong. Only characters on the two base layers (no modifier, Shift) are typed: typographic quotes, dashes, and ellipses are replaced by their plain equivalents, and characters that need Option (such as `@`, `€`, or brackets on a German layout), dead keys, or emoji have no equivalent on the remote PC's layout and are skipped. The number of skipped characters is logged; the full text stays available in OpenWritr. Typing runs in the background and stops as soon as another app becomes frontmost, so the rest of the text is never typed into a different app. The option is off by default. It was verified by the maintainer against a real Windows 365 Cloud PC session through Windows App 11.4.0 with matching German keyboard layouts; other remote layouts, clients, and character sets have not been tested. Test it in a harmless text field first. Typing needs the same Accessibility permission as pasting. diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index 9e4da83..7df1afd 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -1736,7 +1736,7 @@ final class AppViewModel { title: "Typing Failed", message: pasteManager.lastError?.localizedDescription ?? "OpenWritr could not type the transcript.", - recoverySuggestion: "The transcript is retained in OpenWritr. Check Accessibility access, then try again." + recoverySuggestion: "The transcript is retained in OpenWritr. Check that your keyboard layout has keys for the text and that Accessibility access is granted, then try again." ), overlayMessage: "Typing failed" ) diff --git a/Sources/OpenWritr/TextTyping.swift b/Sources/OpenWritr/TextTyping.swift index 27b630e..d828c89 100644 --- a/Sources/OpenWritr/TextTyping.swift +++ b/Sources/OpenWritr/TextTyping.swift @@ -178,8 +178,32 @@ struct SystemRemoteDesktopDetector: RemoteDesktopDetecting { } } +/// One key press per group (Shift down, key down, key up, Shift up), so a group is never split. private struct EventBatch: @unchecked Sendable { - let events: [CGEvent] + let groups: [[CGEvent]] +} + +enum TypingDelivery { + /// Posts each group in order, but only while `stillTargeted` holds before the group starts. + /// Returns how many whole groups were posted; a group is never posted partially. + @discardableResult + nonisolated static func deliver( + _ groups: [[Event]], + stillTargeted: () -> Bool, + post: (Event) -> Void, + pause: () -> Void + ) -> Int { + var delivered = 0 + for group in groups { + guard stillTargeted() else { break } + for event in group { + post(event) + pause() + } + delivered += 1 + } + return delivered + } } struct SystemTextTyper: TextTyping { @@ -199,27 +223,42 @@ struct SystemTextTyper: TextTyping { } if plan.strokes.isEmpty { return text.isEmpty } - guard let source = CGEventSource(stateID: .hidSystemState), - let events = Self.events(for: plan.strokes, source: source) + guard let target = NSWorkspace.shared.frontmostApplication?.processIdentifier, + let source = CGEventSource(stateID: .hidSystemState), + let groups = Self.eventGroups(for: plan.strokes, source: source) else { typingLog.error("Could not create keyboard events for typing") return false } - // Posted off the main thread, in order, with a short pause between events. - let batch = EventBatch(events: events) + // Posted off the main thread, in order, with a short pause between events. Typing can take + // seconds, so it stops as soon as the target app is no longer frontmost: the rest of the + // transcript must never be typed into a different app. + let batch = EventBatch(groups: groups) Self.queue.async { - for event in batch.events { - event.post(tap: .cgAnnotatedSessionEventTap) - usleep(Self.eventSpacing) + let delivered = TypingDelivery.deliver( + batch.groups, + stillTargeted: { Self.isFrontmost(processIdentifier: target) }, + post: { $0.post(tap: .cgAnnotatedSessionEventTap) }, + pause: { usleep(Self.eventSpacing) } + ) + if delivered < batch.groups.count { + typingLog.notice("Stopped typing after \(delivered) of \(batch.groups.count) keys: focus changed") } } return true } - private static func events(for strokes: [KeyStroke], source: CGEventSource) -> [CGEvent]? { - var events: [CGEvent] = [] + private nonisolated static func isFrontmost(processIdentifier: pid_t) -> Bool { + DispatchQueue.main.sync { + NSWorkspace.shared.frontmostApplication?.processIdentifier == processIdentifier + } + } + + private static func eventGroups(for strokes: [KeyStroke], source: CGEventSource) -> [[CGEvent]]? { + var groups: [[CGEvent]] = [] for stroke in strokes { + var events: [CGEvent] = [] let flags: CGEventFlags = stroke.shift ? .maskShift : [] if stroke.shift { guard let shiftDown = modifierEvent(source: source, down: true) else { return nil } @@ -236,8 +275,9 @@ struct SystemTextTyper: TextTyping { guard let shiftUp = modifierEvent(source: source, down: false) else { return nil } events.append(shiftUp) } + groups.append(events) } - return events + return groups } /// Shift is sent as a real modifier key event, because a remote desktop client tracks modifier diff --git a/Tests/OpenWritrTests/TextTypingTests.swift b/Tests/OpenWritrTests/TextTypingTests.swift index d99da7e..f1906bd 100644 --- a/Tests/OpenWritrTests/TextTypingTests.swift +++ b/Tests/OpenWritrTests/TextTypingTests.swift @@ -79,6 +79,54 @@ struct TextTypingPlanTests { @Test func onlyTheWindowsAppIsTreatedAsRemoteDesktop() { #expect(SystemRemoteDesktopDetector.bundleIdentifiers == ["com.microsoft.rdc.macos"]) } + + @Test func deliveryPostsEveryGroupWhileTheTargetStaysFrontmost() { + var posted: [Int] = [] + var pauses = 0 + + let delivered = TypingDelivery.deliver( + [[1, 2], [3], [4, 5, 6]], + stillTargeted: { true }, + post: { posted.append($0) }, + pause: { pauses += 1 } + ) + + #expect(delivered == 3) + #expect(posted == [1, 2, 3, 4, 5, 6]) + #expect(pauses == 6) + } + + @Test func deliveryStopsBeforeTheNextGroupWhenFocusMovesAway() { + var posted: [Int] = [] + var checks = 0 + + let delivered = TypingDelivery.deliver( + [[1, 2], [3, 4], [5, 6]], + stillTargeted: { + checks += 1 + return checks <= 2 + }, + post: { posted.append($0) }, + pause: {} + ) + + #expect(delivered == 2) + #expect(posted == [1, 2, 3, 4], "no key of the third group may be typed into another app") + } + + @Test func deliveryTypesNothingWhenFocusAlreadyMoved() { + var posted: [Int] = [] + + let delivered = TypingDelivery.deliver( + [[1, 2]], + stillTargeted: { false }, + post: { posted.append($0) }, + pause: {} + ) + + #expect(delivered == 0) + #expect(posted.isEmpty) + } } @MainActor