diff --git a/Sources/ZoomItMacCore/App/DemoTypeController.swift b/Sources/ZoomItMacCore/App/DemoTypeController.swift index 43164b2..4fb6bef 100644 --- a/Sources/ZoomItMacCore/App/DemoTypeController.swift +++ b/Sources/ZoomItMacCore/App/DemoTypeController.swift @@ -45,6 +45,7 @@ final class DemoTypeController { private static let maxInputSize = 1_048_576 private static let minTypingDelayMs = 10 private static let maxTypingDelayMs = 100 + private static let maxPauseSeconds = 86_400 private static let typingVariance = 1.0 private static let injectedEventMarker: Int64 = 0x5A495444 private static let endControl = "[end]" @@ -147,7 +148,10 @@ final class DemoTypeController { postKey(keyCode) case .pause(let seconds): if !userDriven, seconds > 0 { - try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + let (nanoseconds, overflow) = UInt64(seconds).multipliedReportingOverflow(by: 1_000_000_000) + if !overflow { + try? await Task.sleep(nanoseconds: nanoseconds) + } } case .paste(let string): paste(string) @@ -187,7 +191,7 @@ final class DemoTypeController { default: if control.hasPrefix("[pause:"), control.hasSuffix("]") { let value = control.dropFirst(7).dropLast() - if let seconds = Int(value) { + if let seconds = Self.parsePauseSeconds(value) { return (.pause(seconds), afterClose) } } @@ -247,6 +251,14 @@ final class DemoTypeController { clean(input) } + static func pauseSecondsForTesting(_ value: String) -> Int? { + parsePauseSeconds(value[...]) + } + + static func unicodeEventUnitsForTesting(_ string: String) -> [[UniChar]] { + unicodeEventUnits(for: string) + } + static func tokensForTesting(_ input: String) -> [TestToken] { let controller = DemoTypeController(settingsStore: UserDefaultsSettingsStore(defaults: UserDefaults(suiteName: "ZoomItMac.DemoTypeController.Test") ?? .standard)) controller.text = clean(input) @@ -354,25 +366,37 @@ final class DemoTypeController { } private static func trimNewline(around control: String, in input: String, trimLeft: Bool, trimRight: Bool) -> String { - var output = input - var searchStart = output.startIndex - while let range = output.range(of: control, range: searchStart.. output.startIndex { - let previous = output.index(before: range.lowerBound) - if output[previous] == "\n" { - output.remove(at: previous) - nextSearchStart = output.index(range.upperBound, offsetBy: -1) + var output = "" + output.reserveCapacity(input.utf8.count) + var searchStart = input.startIndex + while let range = input.range(of: control, range: searchStart.. searchStart { + let previous = input.index(before: range.lowerBound) + if input[previous] == "\n" { + copyEnd = previous } } - if trimRight, nextSearchStart < output.endIndex, output[nextSearchStart] == "\n" { - output.remove(at: nextSearchStart) + output.append(contentsOf: input[searchStart.. Int? { + guard let seconds = Int(value), (0...maxPauseSeconds).contains(seconds) else { + return nil + } + return seconds + } + private func typingDelayMilliseconds(for slider: Int) -> Int { let clamped = min(max(slider, Self.minTypingDelayMs), Self.maxTypingDelayMs) return (Self.minTypingDelayMs + Self.maxTypingDelayMs) - clamped @@ -523,20 +547,26 @@ final class DemoTypeController { } private func type(_ string: String) { - for scalar in string.unicodeScalars { - let source = CGEventSource(stateID: .combinedSessionState) - let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true) - var value = UniChar(scalar.value) - down?.keyboardSetUnicodeString(stringLength: 1, unicodeString: &value) - let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) - up?.keyboardSetUnicodeString(stringLength: 1, unicodeString: &value) - markInjected(down) - markInjected(up) - down?.post(tap: .cghidEventTap) - up?.post(tap: .cghidEventTap) + for units in Self.unicodeEventUnits(for: string) { + units.withUnsafeBufferPointer { buffer in + guard let baseAddress = buffer.baseAddress else { return } + let source = CGEventSource(stateID: .combinedSessionState) + let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true) + down?.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: baseAddress) + let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) + up?.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: baseAddress) + markInjected(down) + markInjected(up) + down?.post(tap: .cghidEventTap) + up?.post(tap: .cghidEventTap) + } } } + private static func unicodeEventUnits(for string: String) -> [[UniChar]] { + string.unicodeScalars.map { Array(String($0).utf16) } + } + private func postKey(_ keyCode: CGKeyCode) { let source = CGEventSource(stateID: .combinedSessionState) let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true) diff --git a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift index b952feb..9649459 100644 --- a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift +++ b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift @@ -45,6 +45,8 @@ public enum SelfTestRunner { try testDemoTypeSettingsRoundTrip() try testDemoTypeScriptCleaningAndTokens() try testDemoTypeScriptDecoding() + try testDemoTypePauseBounds() + try testDemoTypeUnicodeEncoding() try testDemoTypeTypingDelayRange() try testDemoTypeUserDrivenStepStopsAtEnd() #endif @@ -384,6 +386,19 @@ public enum SelfTestRunner { private static func testDemoTypeScriptCleaningAndTokens() throws { let cleaned = DemoTypeController.cleanForTesting("\u{0001}\nhello\n[end]\nworld\n[paste]\nchunk\n[/paste]\n[end]\n ") try expect(cleaned == "hello[end]world\n[paste]chunk[/paste][end]", "Unexpected DemoType cleaned script: \(cleaned)") + try expect( + DemoTypeController.cleanForTesting("hello\n[end]") == "hello[end]", + "Expected DemoType cleaning to handle [end] at EOF without reusing a stale String.Index" + ) + try expect( + DemoTypeController.cleanForTesting("hello\n[/paste]") == "hello[/paste]", + "Expected DemoType cleaning to handle [/paste] at EOF without reusing a stale String.Index" + ) + try expect( + DemoTypeController.cleanForTesting(String(repeating: "line\n[end]\n", count: 10_000)) + == String(repeating: "line[end]", count: 10_000), + "Expected repeated DemoType controls to clean correctly" + ) let tokens = DemoTypeController.tokensForTesting("a[pause:2][enter][up][down][left][right][paste]hi[/paste][end]") try expect(tokens == [ @@ -405,6 +420,30 @@ public enum SelfTestRunner { try expect(DemoTypeController.decodeForTesting(Data([0xFE, 0xFF, 0x00, 0x62, 0x00, 0x65])) == "be", "Expected UTF-16BE DemoType text") } + private static func testDemoTypePauseBounds() throws { + try expect(DemoTypeController.pauseSecondsForTesting("0") == 0, "Expected zero-second DemoType pause to remain valid") + try expect(DemoTypeController.pauseSecondsForTesting("86400") == 86_400, "Expected one-day DemoType pause to remain valid") + try expect(DemoTypeController.pauseSecondsForTesting("-1") == nil, "Expected negative DemoType pause to be rejected") + try expect(DemoTypeController.pauseSecondsForTesting("86401") == nil, "Expected oversized DemoType pause to be rejected") + try expect(DemoTypeController.pauseSecondsForTesting("20000000000") == nil, "Expected overflowing DemoType pause to be rejected") + } + + private static func testDemoTypeUnicodeEncoding() throws { + try expect( + DemoTypeController.unicodeEventUnitsForTesting("A") == [[0x0041]], + "Expected BMP DemoType text to encode as one UTF-16 code unit" + ) + try expect( + DemoTypeController.unicodeEventUnitsForTesting("😀") == [[0xD83D, 0xDE00]], + "Expected non-BMP DemoType text to encode as a UTF-16 surrogate pair" + ) + let longGrapheme = "e" + String(repeating: "\u{0301}", count: 24) + let eventUnits = DemoTypeController.unicodeEventUnitsForTesting(longGrapheme) + try expect(eventUnits.count == 25, "Expected one DemoType event per Unicode scalar") + try expect(eventUnits.allSatisfy { $0.count <= 2 }, "Expected every DemoType event to remain below the CoreGraphics UTF-16 limit") + try expect(eventUnits.flatMap { $0 } == Array(longGrapheme.utf16), "Expected scalar-sized DemoType events to preserve the complete grapheme") + } + private static func testDemoTypeTypingDelayRange() throws { try expect(DemoTypeController.typingDelayRangeForTesting(slider: 55) == 1...110, "Expected midpoint DemoType delay to match Windows speed +/- speed") try expect(DemoTypeController.typingDelayRangeForTesting(slider: 100) == 1...20, "Expected fastest DemoType delay range")