diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19879f4..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
@@ -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 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
### Fixed
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
diff --git a/README.md b/README.md
index 6526ef5..ff875ec 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,14 @@ 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 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. 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.
+
## 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..7df1afd 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 that your keyboard layout has keys for the text and that Accessibility access is granted, 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..951b267 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 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)
+ .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..d828c89
--- /dev/null
+++ b/Sources/OpenWritr/TextTyping.swift
@@ -0,0 +1,292 @@
+import Carbon.HIToolbox
+import Cocoa
+import CoreGraphics
+import os.log
+
+private let typingLog = Logger(subsystem: "com.openwritr.app", category: "TextTyping")
+
+/// A physical key on the Mac keyboard, optionally with Shift held.
+struct KeyStroke: Equatable, Sendable {
+ let keyCode: CGKeyCode
+ let shift: Bool
+}
+
+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)
+
+ /// 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
+
+ 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())
+
+ 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)
+ }
+ }
+ }
+ 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()
+ }
+ 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 key strokes into the frontmost app. Returns false if nothing could 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
+ }
+}
+
+/// 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 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 {
+ 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 layout = SystemKeyboardLayout.currentCharacterMap() else {
+ typingLog.error("Could not read the current keyboard layout")
+ 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 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. 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 {
+ 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 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 }
+ 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)
+ }
+ groups.append(events)
+ }
+ return groups
+ }
+
+ /// 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
new file mode 100644
index 0000000..f1906bd
--- /dev/null
+++ b/Tests/OpenWritrTests/TextTypingTests.swift
@@ -0,0 +1,337 @@
+import AppKit
+import Testing
+@testable import OpenWritr
+
+@Suite("Text typing plan")
+struct TextTypingPlanTests {
+ 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() {
+ 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 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)
+ }
+
+ @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() {
+ #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
+@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 {
+ @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) {}
+}