Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 54 additions & 24 deletions Sources/ZoomItMacCore/App/DemoTypeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.endIndex) {
var nextSearchStart = range.upperBound
if trimLeft, range.lowerBound > 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..<input.endIndex) {
var copyEnd = range.lowerBound
if trimLeft, range.lowerBound > 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..<copyEnd])
output.append(control)

var nextSearchStart = range.upperBound
if trimRight, nextSearchStart < input.endIndex, input[nextSearchStart] == "\n" {
nextSearchStart = input.index(after: nextSearchStart)
}
searchStart = nextSearchStart
}
output.append(contentsOf: input[searchStart...])
return output
}

private static func parsePauseSeconds(_ value: Substring) -> 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
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public enum SelfTestRunner {
try testDemoTypeSettingsRoundTrip()
try testDemoTypeScriptCleaningAndTokens()
try testDemoTypeScriptDecoding()
try testDemoTypePauseBounds()
try testDemoTypeUnicodeEncoding()
try testDemoTypeTypingDelayRange()
try testDemoTypeUserDrivenStepStopsAtEnd()
#endif
Expand Down Expand Up @@ -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 == [
Expand All @@ -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")
Expand Down