mcp-baepsae 6.2.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,16 @@ func handleDescribeUI(_ parsed: ParsedOptions) throws -> Int32 {
24
24
  // For macOS apps, start from window level, no iOSContentGroup filtering
25
25
  if !parsed.flags.contains("--all") {
26
26
  let windows = Children(appRoot)
27
- if let firstWindow = windows.first {
27
+ if let windowSelector = parsed.options["--window"] {
28
+ if let idx = Int(windowSelector), idx < windows.count {
29
+ targetRoot = windows[idx]
30
+ } else {
31
+ targetRoot = windows.first(where: {
32
+ let title = StringAttribute($0, kAXTitleAttribute as CFString) ?? ""
33
+ return title.localizedCaseInsensitiveContains(windowSelector)
34
+ }) ?? windows.first ?? appRoot
35
+ }
36
+ } else if let firstWindow = windows.first {
28
37
  targetRoot = firstWindow
29
38
  }
30
39
  }
@@ -72,6 +81,31 @@ func handleDescribeUI(_ parsed: ParsedOptions) throws -> Int32 {
72
81
  }
73
82
  }
74
83
 
84
+ // Page-size based pagination for large trees
85
+ if let pageSizeStr = parsed.options["--page-size"], let pageSize = Int(pageSizeStr) {
86
+ let page = Int(parsed.options["--page"] ?? "0") ?? 0
87
+ var totalCount: CFIndex = 0
88
+ AXUIElementGetAttributeValueCount(targetRoot, kAXChildrenAttribute as CFString, &totalCount)
89
+ let offset = page * pageSize
90
+ if offset >= Int(totalCount) {
91
+ print("Page \(page) is beyond total children count (\(totalCount)).")
92
+ print("total=\(totalCount) pageSize=\(pageSize) pages=\((Int(totalCount) + pageSize - 1) / max(pageSize, 1))")
93
+ return 0
94
+ }
95
+ var pageChildren: CFArray?
96
+ let fetchCount = min(pageSize, Int(totalCount) - offset)
97
+ AXUIElementCopyAttributeValues(targetRoot, kAXChildrenAttribute as CFString, CFIndex(offset), CFIndex(fetchCount), &pageChildren)
98
+ let totalPages = (Int(totalCount) + pageSize - 1) / max(pageSize, 1)
99
+ print("page=\(page)/\(max(totalPages - 1, 0)) total=\(totalCount) pageSize=\(pageSize) showing=\(offset)..\(offset + fetchCount - 1)")
100
+ if let children = pageChildren as? [AXUIElement] {
101
+ for child in children {
102
+ let childLines = describeAccessibilityTree(from: child, options: descOpts)
103
+ print(childLines.joined(separator: "\n"))
104
+ }
105
+ }
106
+ return 0
107
+ }
108
+
75
109
  var lines = describeAccessibilityTree(from: targetRoot, options: descOpts)
76
110
  if lines.isEmpty {
77
111
  throw NativeError.commandFailed("No accessibility elements found.")
@@ -366,6 +400,34 @@ func handleType(_ parsed: ParsedOptions) throws -> Int32 {
366
400
  }
367
401
 
368
402
  let methodStr = parsed.options["--method"] ?? "auto"
403
+
404
+ if methodStr == "ax" {
405
+ let appRoot = try accessibilityRootElement(for: target)
406
+ var focusedRef: CFTypeRef?
407
+ let status = AXUIElementCopyAttributeValue(
408
+ appRoot,
409
+ "AXFocusedUIElement" as CFString,
410
+ &focusedRef
411
+ )
412
+ if status == .success, let focused = focusedRef {
413
+ let setResult = AXUIElementSetAttributeValue(
414
+ focused as! AXUIElement,
415
+ kAXValueAttribute as CFString,
416
+ text as CFTypeRef
417
+ )
418
+ if setResult == .success {
419
+ print("Set value via AX API.")
420
+ return 0
421
+ }
422
+ fputs("AX setValue failed (status \(setResult.rawValue)), falling back to paste\n", stderr)
423
+ } else {
424
+ fputs("No focused element found, falling back to paste\n", stderr)
425
+ }
426
+ // Fallback to paste
427
+ try pasteText(text, target: target)
428
+ return 0
429
+ }
430
+
369
431
  let usePaste: Bool
370
432
  switch methodStr {
371
433
  case "paste":
@@ -373,11 +435,7 @@ func handleType(_ parsed: ParsedOptions) throws -> Int32 {
373
435
  case "keyboard":
374
436
  usePaste = false
375
437
  default: // auto
376
- if case .simulator = target {
377
- usePaste = true
378
- } else {
379
- usePaste = false
380
- }
438
+ usePaste = true // paste is more reliable for all targets including CJK
381
439
  }
382
440
 
383
441
  if usePaste {
@@ -410,7 +468,7 @@ func pasteText(_ text: String, target: TargetApp) throws {
410
468
 
411
469
  // Cmd+V: Command keycode=55, V keycode=9
412
470
  sendKeyCombo(modifiers: [55], key: 9)
413
- Thread.sleep(forTimeInterval: 0.15)
471
+ Thread.sleep(forTimeInterval: 0.3)
414
472
 
415
473
  // Restore original clipboard content
416
474
  pasteboard.clearContents()
@@ -462,6 +520,67 @@ func handleSwipe(_ parsed: ParsedOptions) throws -> Int32 {
462
520
  return 0
463
521
  }
464
522
 
523
+ // MARK: - detect-dialog
524
+
525
+ func handleDetectDialog(_ parsed: ParsedOptions) throws -> Int32 {
526
+ let target = try resolveTarget(from: parsed)
527
+ try ensureAccessibilityTrusted()
528
+ try activateTarget(target)
529
+ let appRoot = try accessibilityRootElement(for: target)
530
+ let windows = Children(appRoot)
531
+
532
+ var dialogs: [[String: Any]] = []
533
+ for window in windows {
534
+ let subrole = StringAttribute(window, kAXSubroleAttribute as CFString) ?? ""
535
+ let isDialog = ["AXDialog", "AXSheet", "AXSystemDialog", "AXSystemFloatingWindow"].contains(subrole)
536
+
537
+ var modalRef: CFTypeRef?
538
+ let modalStatus = AXUIElementCopyAttributeValue(window, kAXModalAttribute as CFString, &modalRef)
539
+ let isModal = (modalStatus == .success && (modalRef as? Bool) == true)
540
+
541
+ if isDialog || isModal {
542
+ let title = StringAttribute(window, kAXTitleAttribute as CFString) ?? ""
543
+ var buttons: [String] = []
544
+
545
+ func findButtons(in elements: [UIElement]) {
546
+ for element in elements {
547
+ let role = StringAttribute(element, kAXRoleAttribute as CFString)
548
+ if role == "AXButton" {
549
+ if let btnTitle = StringAttribute(element, kAXTitleAttribute as CFString), !btnTitle.isEmpty {
550
+ buttons.append(btnTitle)
551
+ }
552
+ }
553
+ findButtons(in: Children(element))
554
+ }
555
+ }
556
+ findButtons(in: Children(window))
557
+
558
+ dialogs.append([
559
+ "type": subrole.isEmpty ? "modal" : subrole,
560
+ "title": title,
561
+ "isModal": isModal,
562
+ "buttons": buttons,
563
+ ])
564
+ }
565
+ }
566
+
567
+ if dialogs.isEmpty {
568
+ print("{\"hasDialog\":false,\"dialogs\":[]}")
569
+ } else {
570
+ var jsonParts: [String] = []
571
+ for d in dialogs {
572
+ let type = d["type"] as? String ?? ""
573
+ let title = (d["title"] as? String ?? "").replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
574
+ let isModal = d["isModal"] as? Bool ?? false
575
+ let btns = d["buttons"] as? [String] ?? []
576
+ let btnsJson = "[" + btns.map { "\"\($0.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\"" }.joined(separator: ",") + "]"
577
+ jsonParts.append("{\"type\":\"\(type)\",\"title\":\"\(title)\",\"isModal\":\(isModal),\"buttons\":\(btnsJson)}")
578
+ }
579
+ print("{\"hasDialog\":true,\"dialogs\":[\(jsonParts.joined(separator: ","))]}")
580
+ }
581
+ return 0
582
+ }
583
+
465
584
  func handleScroll(_ parsed: ParsedOptions) throws -> Int32 {
466
585
  let target = try resolveTarget(from: parsed)
467
586
  try ensureAccessibilityTrusted()
@@ -503,3 +622,305 @@ func handleScroll(_ parsed: ParsedOptions) throws -> Int32 {
503
622
  }
504
623
  return 0
505
624
  }
625
+
626
+ // MARK: - handleSetUIValue (F010)
627
+
628
+ func handleSetUIValue(_ parsed: ParsedOptions) throws -> Int32 {
629
+ let target = try resolveTarget(from: parsed)
630
+ try ensureAccessibilityTrusted()
631
+ try activateTarget(target)
632
+ let appRoot = try accessibilityRootElement(for: target)
633
+
634
+ let attributeStr = parsed.options["--attribute"] ?? "value"
635
+ let valueStr = try requiredOption("--value", from: parsed)
636
+
637
+ // Find element
638
+ let element: AXUIElement
639
+ if let accessibilityId = parsed.options["--id"] {
640
+ guard let found = findAccessibilityElement(in: appRoot, identifier: accessibilityId, label: nil) else {
641
+ throw NativeError.commandFailed("No element with id: \(accessibilityId)")
642
+ }
643
+ element = found
644
+ } else if let label = parsed.options["--label"] {
645
+ guard let found = findAccessibilityElement(in: appRoot, identifier: nil, label: label) else {
646
+ throw NativeError.commandFailed("No element with label: \(label)")
647
+ }
648
+ element = found
649
+ } else {
650
+ var focusedRef: CFTypeRef?
651
+ let status = AXUIElementCopyAttributeValue(appRoot, "AXFocusedUIElement" as CFString, &focusedRef)
652
+ guard status == .success, let ref = focusedRef else {
653
+ throw NativeError.commandFailed("No focused element and no --id or --label provided.")
654
+ }
655
+ element = ref as! AXUIElement
656
+ }
657
+
658
+ // 속성 이름 결정 (settable 체크와 set 호출 양쪽에 공통 사용)
659
+ let axAttributeName: String
660
+ switch attributeStr {
661
+ case "value": axAttributeName = kAXValueAttribute as String
662
+ case "selectedTextRange": axAttributeName = "AXSelectedTextRange"
663
+ case "focused": axAttributeName = kAXFocusedAttribute as String
664
+ default:
665
+ throw NativeError.invalidArguments("Unsupported attribute: \(attributeStr). Use: value, selectedTextRange, focused.")
666
+ }
667
+
668
+ // 쓰기 가능 여부 사전 확인
669
+ var settable: DarwinBoolean = false
670
+ AXUIElementIsAttributeSettable(element, axAttributeName as CFString, &settable)
671
+ if !settable.boolValue {
672
+ throw NativeError.commandFailed(
673
+ "Attribute '\(attributeStr)' is not writable on this element. "
674
+ + "Use enumerate_ui to discover which attributes are settable."
675
+ )
676
+ }
677
+
678
+ switch attributeStr {
679
+ case "value":
680
+ let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, valueStr as CFTypeRef)
681
+ guard result == .success else {
682
+ throw NativeError.commandFailed("Failed to set value (AXError \(result.rawValue)). Element may not support writable value.")
683
+ }
684
+ print("Set value: \(valueStr)")
685
+
686
+ case "selectedTextRange":
687
+ // Parse "location,length" format
688
+ let parts = valueStr.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
689
+ guard parts.count == 2 else {
690
+ throw NativeError.invalidArguments("selectedTextRange requires 'location,length' format (e.g. '10,5').")
691
+ }
692
+ var range = CFRange(location: parts[0], length: parts[1])
693
+ guard let axValue = AXValueCreate(.cfRange, &range) else {
694
+ throw NativeError.commandFailed("Failed to create AXValue for range.")
695
+ }
696
+ let result = AXUIElementSetAttributeValue(element, "AXSelectedTextRange" as CFString, axValue)
697
+ guard result == .success else {
698
+ throw NativeError.commandFailed("Failed to set selectedTextRange (AXError \(result.rawValue)).")
699
+ }
700
+ print("Set selectedTextRange: location=\(parts[0]), length=\(parts[1])")
701
+
702
+ case "focused":
703
+ let boolValue = (valueStr == "true" || valueStr == "1")
704
+ let result = AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, boolValue as CFTypeRef)
705
+ guard result == .success else {
706
+ throw NativeError.commandFailed("Failed to set focused (AXError \(result.rawValue)).")
707
+ }
708
+ print("Set focused: \(boolValue)")
709
+
710
+ default:
711
+ throw NativeError.invalidArguments("Unsupported attribute: \(attributeStr). Use: value, selectedTextRange, focused.")
712
+ }
713
+ return 0
714
+ }
715
+
716
+ // MARK: - handleHitTest (F003)
717
+
718
+ func handleHitTest(_ parsed: ParsedOptions) throws -> Int32 {
719
+ try ensureAccessibilityTrusted()
720
+ guard let xStr = parsed.options["-x"], let yStr = parsed.options["-y"],
721
+ let x = Float(xStr), let y = Float(yStr) else {
722
+ throw NativeError.invalidArguments("hit-test requires -x and -y coordinates.")
723
+ }
724
+
725
+ let systemWide = AXUIElementCreateSystemWide()
726
+ var element: AXUIElement?
727
+ let status = AXUIElementCopyElementAtPosition(systemWide, x, y, &element)
728
+ guard status == .success, let found = element else {
729
+ throw NativeError.commandFailed("No accessibility element found at (\(x), \(y)). AXError: \(status.rawValue)")
730
+ }
731
+
732
+ let role = StringAttribute(found, kAXRoleAttribute as CFString) ?? ""
733
+ let subrole = StringAttribute(found, kAXSubroleAttribute as CFString) ?? ""
734
+ let title = StringAttribute(found, kAXTitleAttribute as CFString) ?? ""
735
+ let identifier = StringAttribute(found, "AXIdentifier" as CFString) ?? ""
736
+ let value = StringAttribute(found, kAXValueAttribute as CFString) ?? ""
737
+ let description = StringAttribute(found, kAXDescriptionAttribute as CFString) ?? ""
738
+
739
+ var frameStr = ""
740
+ if let frame = FrameAttribute(found) {
741
+ frameStr = "\(formatFloat(frame.origin.x)),\(formatFloat(frame.origin.y)),\(formatFloat(frame.width)),\(formatFloat(frame.height))"
742
+ }
743
+
744
+ var pid: pid_t = 0
745
+ AXUIElementGetPid(found, &pid)
746
+ let appName = NSWorkspace.shared.runningApplications.first(where: { $0.processIdentifier == pid })?.localizedName ?? ""
747
+
748
+ print("role=\(role) subrole=\(subrole) title=\(title) id=\(identifier) value=\(value) desc=\(description) frame=\(frameStr) pid=\(pid) app=\(appName)")
749
+ return 0
750
+ }
751
+
752
+ // MARK: - handleEnumerateUI (F005)
753
+
754
+ func handleEnumerateUI(_ parsed: ParsedOptions) throws -> Int32 {
755
+ let target = try resolveTarget(from: parsed)
756
+ try ensureAccessibilityTrusted()
757
+ try activateTarget(target)
758
+ let appRoot = try accessibilityRootElement(for: target)
759
+
760
+ let element: AXUIElement
761
+ if let accessibilityId = parsed.options["--id"] {
762
+ guard let found = findAccessibilityElement(in: appRoot, identifier: accessibilityId, label: nil) else {
763
+ throw NativeError.commandFailed("No element with id: \(accessibilityId)")
764
+ }
765
+ element = found
766
+ } else if let label = parsed.options["--label"] {
767
+ guard let found = findAccessibilityElement(in: appRoot, identifier: nil, label: label) else {
768
+ throw NativeError.commandFailed("No element with label: \(label)")
769
+ }
770
+ element = found
771
+ } else {
772
+ var focusedRef: CFTypeRef?
773
+ let status = AXUIElementCopyAttributeValue(appRoot, "AXFocusedUIElement" as CFString, &focusedRef)
774
+ guard status == .success, let ref = focusedRef else {
775
+ throw NativeError.commandFailed("No focused element and no --id or --label provided.")
776
+ }
777
+ element = ref as! AXUIElement
778
+ }
779
+
780
+ // 속성 목록
781
+ var attrNames: CFArray?
782
+ AXUIElementCopyAttributeNames(element, &attrNames)
783
+ var attributes: [(name: String, settable: Bool)] = []
784
+ if let names = attrNames as? [String] {
785
+ for name in names {
786
+ var settable: DarwinBoolean = false
787
+ AXUIElementIsAttributeSettable(element, name as CFString, &settable)
788
+ attributes.append((name: name, settable: settable.boolValue))
789
+ }
790
+ }
791
+
792
+ // 액션 목록
793
+ var actionNames: CFArray?
794
+ AXUIElementCopyActionNames(element, &actionNames)
795
+ var actions: [(name: String, description: String)] = []
796
+ if let names = actionNames as? [String] {
797
+ for name in names {
798
+ var desc: CFString?
799
+ AXUIElementCopyActionDescription(element, name as CFString, &desc)
800
+ actions.append((name: name, description: (desc as String?) ?? ""))
801
+ }
802
+ }
803
+
804
+ // 파라미터화된 속성 목록
805
+ var paramNames: CFArray?
806
+ AXUIElementCopyParameterizedAttributeNames(element, &paramNames)
807
+ let parameterized = (paramNames as? [String]) ?? []
808
+
809
+ let attrJson = attributes.map { a in
810
+ let escapedName = a.name.replacingOccurrences(of: "\"", with: "\\\"")
811
+ return "{\"name\":\"\(escapedName)\",\"settable\":\(a.settable)}"
812
+ }.joined(separator: ",")
813
+
814
+ let actJson = actions.map { a in
815
+ let escapedName = a.name.replacingOccurrences(of: "\"", with: "\\\"")
816
+ let escapedDesc = a.description.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
817
+ return "{\"name\":\"\(escapedName)\",\"description\":\"\(escapedDesc)\"}"
818
+ }.joined(separator: ",")
819
+
820
+ let paramJson = parameterized.map { "\"" + $0.replacingOccurrences(of: "\"", with: "\\\"") + "\"" }.joined(separator: ",")
821
+
822
+ print("{\"attributes\":[\(attrJson)],\"actions\":[\(actJson)],\"parameterizedAttributes\":[\(paramJson)]}")
823
+ return 0
824
+ }
825
+
826
+ // MARK: - handleReadUIParam (F012)
827
+
828
+ func handleReadUIParam(_ parsed: ParsedOptions) throws -> Int32 {
829
+ let target = try resolveTarget(from: parsed)
830
+ try ensureAccessibilityTrusted()
831
+ try activateTarget(target)
832
+ let appRoot = try accessibilityRootElement(for: target)
833
+
834
+ let attributeStr = try requiredOption("--attribute", from: parsed)
835
+ let paramStr = try requiredOption("--param", from: parsed)
836
+
837
+ // Find element
838
+ let element: AXUIElement
839
+ if let accessibilityId = parsed.options["--id"] {
840
+ guard let found = findAccessibilityElement(in: appRoot, identifier: accessibilityId, label: nil) else {
841
+ throw NativeError.commandFailed("No element with id: \(accessibilityId)")
842
+ }
843
+ element = found
844
+ } else if let label = parsed.options["--label"] {
845
+ guard let found = findAccessibilityElement(in: appRoot, identifier: nil, label: label) else {
846
+ throw NativeError.commandFailed("No element with label: \(label)")
847
+ }
848
+ element = found
849
+ } else {
850
+ var focusedRef: CFTypeRef?
851
+ let status = AXUIElementCopyAttributeValue(appRoot, "AXFocusedUIElement" as CFString, &focusedRef)
852
+ guard status == .success, let ref = focusedRef else {
853
+ throw NativeError.commandFailed("No focused element and no --id or --label provided.")
854
+ }
855
+ element = ref as! AXUIElement
856
+ }
857
+
858
+ let axAttribute: String
859
+ let parameter: CFTypeRef
860
+
861
+ switch attributeStr {
862
+ case "stringForRange", "boundsForRange":
863
+ let parts = paramStr.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
864
+ guard parts.count == 2 else {
865
+ throw NativeError.invalidArguments("\(attributeStr) requires 'location,length' format.")
866
+ }
867
+ var range = CFRange(location: parts[0], length: parts[1])
868
+ guard let axValue = AXValueCreate(.cfRange, &range) else {
869
+ throw NativeError.commandFailed("Failed to create AXValue for range.")
870
+ }
871
+ parameter = axValue
872
+ axAttribute = attributeStr == "stringForRange"
873
+ ? "AXStringForRange"
874
+ : "AXBoundsForRange"
875
+
876
+ case "lineForIndex":
877
+ guard let index = Int(paramStr) else {
878
+ throw NativeError.invalidArguments("lineForIndex requires a numeric index.")
879
+ }
880
+ parameter = index as CFTypeRef
881
+ axAttribute = "AXLineForIndex"
882
+
883
+ case "rangeForLine":
884
+ guard let line = Int(paramStr) else {
885
+ throw NativeError.invalidArguments("rangeForLine requires a numeric line number.")
886
+ }
887
+ parameter = line as CFTypeRef
888
+ axAttribute = "AXRangeForLine"
889
+
890
+ default:
891
+ throw NativeError.invalidArguments("Unsupported parameterized attribute: \(attributeStr). Use: stringForRange, boundsForRange, lineForIndex, rangeForLine.")
892
+ }
893
+
894
+ var resultRef: CFTypeRef?
895
+ let status = AXUIElementCopyParameterizedAttributeValue(element, axAttribute as CFString, parameter, &resultRef)
896
+ guard status == .success, let result = resultRef else {
897
+ throw NativeError.commandFailed("Failed to read \(attributeStr) (AXError \(status.rawValue)).")
898
+ }
899
+
900
+ if let str = result as? String {
901
+ print(str)
902
+ } else if let num = result as? NSNumber {
903
+ print(num.stringValue)
904
+ } else if CFGetTypeID(result) == AXValueGetTypeID() {
905
+ let axVal = result as! AXValue
906
+ let valType = AXValueGetType(axVal)
907
+ if valType == .cfRange {
908
+ var range = CFRange(location: 0, length: 0)
909
+ AXValueGetValue(axVal, .cfRange, &range)
910
+ print("\(range.location),\(range.length)")
911
+ } else if valType == .cgRect {
912
+ var rect = CGRect.zero
913
+ AXValueGetValue(axVal, .cgRect, &rect)
914
+ print("\(rect.origin.x),\(rect.origin.y),\(rect.width),\(rect.height)")
915
+ } else if valType == .cgPoint {
916
+ var point = CGPoint.zero
917
+ AXValueGetValue(axVal, .cgPoint, &point)
918
+ print("\(point.x),\(point.y)")
919
+ } else {
920
+ print(String(describing: result))
921
+ }
922
+ } else {
923
+ print(String(describing: result))
924
+ }
925
+ return 0
926
+ }
@@ -36,6 +36,17 @@ let supportedCommands: Set<String> = [
36
36
  "menu-action",
37
37
  "get-focused-app",
38
38
  "clipboard",
39
+ "list-input-sources",
40
+ "input-source",
41
+ "focus-window",
42
+ "read-ui-value",
43
+ "context-menu-action",
44
+ "detect-dialog",
45
+ "set-ui-value",
46
+ "read-ui-param",
47
+ "hit-test",
48
+ "enumerate-ui",
49
+ "watch-notification",
39
50
  ]
40
51
 
41
52
  // MARK: - Argument Parsing
@@ -291,7 +302,13 @@ func accessibilityRootElement(for target: TargetApp) throws -> UIElement {
291
302
  case .simulator:
292
303
  return try simulatorAccessibilityRootElement()
293
304
  case .macApp(let pid, _, _):
294
- return AXUIElementCreateApplication(pid)
305
+ let appElement = AXUIElementCreateApplication(pid)
306
+ // 무거운 앱에서 기본 시스템 타임아웃(~6초)이 너무 짧은 경우 대비
307
+ // BAEPSAE_AX_TIMEOUT 환경변수(초)로 재정의 가능, 기본값 10초
308
+ let timeout = Float(ProcessInfo.processInfo.environment["BAEPSAE_AX_TIMEOUT"]
309
+ .flatMap(Double.init) ?? 10.0)
310
+ AXUIElementSetMessagingTimeout(appElement, timeout)
311
+ return appElement
295
312
  }
296
313
  }
297
314
 
@@ -1482,9 +1499,17 @@ func activateTarget(_ target: TargetApp) throws {
1482
1499
  try activateSimulator(udid: udid)
1483
1500
  case .macApp(let pid, _, _):
1484
1501
  let apps = NSWorkspace.shared.runningApplications.filter { $0.processIdentifier == pid }
1485
- if let app = apps.first {
1486
- app.activate(options: [.activateAllWindows])
1502
+ guard let app = apps.first else {
1503
+ throw NativeError.commandFailed("App with pid \(pid) not found.")
1504
+ }
1505
+ app.activate(options: [.activateAllWindows])
1506
+ // Poll for activation (max 1 second)
1507
+ let deadline = Date().addingTimeInterval(1.0)
1508
+ while Date() < deadline {
1509
+ if app.isActive { return }
1510
+ Thread.sleep(forTimeInterval: 0.05)
1487
1511
  }
1512
+ fputs("Warning: app activation may not have completed within timeout\n", stderr)
1488
1513
  }
1489
1514
  }
1490
1515
 
@@ -1520,6 +1545,7 @@ func postMouseEvent(type: CGEventType, point: CGPoint, button: CGMouseButton = .
1520
1545
 
1521
1546
  func sendClick(at point: CGPoint) {
1522
1547
  postMouseEvent(type: .leftMouseDown, point: point)
1548
+ usleep(20_000) // 20ms prevents some apps from ignoring instant clicks
1523
1549
  postMouseEvent(type: .leftMouseUp, point: point)
1524
1550
  }
1525
1551
 
@@ -1607,6 +1633,21 @@ func sendDrag(from start: CGPoint, to end: CGPoint, holdDuration: Double, moveDu
1607
1633
  postMouseEvent(type: .leftMouseUp, point: end)
1608
1634
  }
1609
1635
 
1636
+ // MARK: - Keycode to CGEventFlags Mapping
1637
+
1638
+ let keycodeToFlag: [Int: CGEventFlags] = [
1639
+ 0x37: .maskCommand, // Left Command (55)
1640
+ 0x36: .maskCommand, // Right Command (54)
1641
+ 0x38: .maskShift, // Left Shift (56)
1642
+ 0x3C: .maskShift, // Right Shift (60)
1643
+ 0x3A: .maskAlternate, // Left Option (58)
1644
+ 0x3D: .maskAlternate, // Right Option (61)
1645
+ 0x3B: .maskControl, // Left Control (59)
1646
+ 0x3E: .maskControl, // Right Control (62)
1647
+ 0x39: .maskAlphaShift, // Caps Lock (57)
1648
+ 0x3F: .maskSecondaryFn, // Fn (63)
1649
+ ]
1650
+
1610
1651
  // MARK: - Keyboard Events
1611
1652
 
1612
1653
  func sendKeyPress(keyCode: Int, duration: Double?) {
@@ -1622,34 +1663,54 @@ func sendKeyPress(keyCode: Int, duration: Double?) {
1622
1663
 
1623
1664
  func sendKeyCombo(modifiers: [Int], key: Int) {
1624
1665
  let source = CGEventSource(stateID: .hidSystemState)
1666
+
1667
+ // Convert keycodes to CGEventFlags
1668
+ var flags: CGEventFlags = []
1669
+ for modifier in modifiers {
1670
+ if let flag = keycodeToFlag[modifier] {
1671
+ flags.insert(flag)
1672
+ }
1673
+ }
1674
+
1675
+ // Send modifier key-down events with flags (backward compat with apps that watch key events)
1625
1676
  for modifier in modifiers {
1626
1677
  let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(modifier), keyDown: true)
1678
+ event?.flags = flags
1627
1679
  event?.post(tap: .cghidEventTap)
1628
1680
  }
1681
+ usleep(30_000) // 30ms for modifiers to register
1682
+
1683
+ // Ensure modifier key-up always happens (prevent stuck modifiers)
1684
+ defer {
1685
+ usleep(30_000)
1686
+ for modifier in modifiers.reversed() {
1687
+ let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(modifier), keyDown: false)
1688
+ event?.post(tap: .cghidEventTap)
1689
+ }
1690
+ }
1691
+
1692
+ // Main key with flags set (critical: many apps only check flags, not key events)
1629
1693
  let keyDown = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(key), keyDown: true)
1630
- let keyUp = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(key), keyDown: false)
1694
+ keyDown?.flags = flags
1631
1695
  keyDown?.post(tap: .cghidEventTap)
1696
+
1697
+ let keyUp = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(key), keyDown: false)
1698
+ keyUp?.flags = flags
1632
1699
  keyUp?.post(tap: .cghidEventTap)
1633
- for modifier in modifiers.reversed() {
1634
- let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(modifier), keyDown: false)
1635
- event?.post(tap: .cghidEventTap)
1636
- }
1637
1700
  }
1638
1701
 
1639
- func sendText(_ text: String) {
1702
+ func sendText(_ text: String, charDelay: Double = 0.01) {
1640
1703
  let source = CGEventSource(stateID: .hidSystemState)
1641
- for scalar in text.unicodeScalars {
1642
- let value = scalar.value
1643
- guard value <= UInt16.max else {
1644
- continue
1645
- }
1646
- var char = UniChar(value)
1704
+ for char in text {
1705
+ var utf16Chars = Array(char.utf16)
1647
1706
  let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true)
1648
- keyDown?.keyboardSetUnicodeString(stringLength: 1, unicodeString: &char)
1707
+ keyDown?.keyboardSetUnicodeString(stringLength: utf16Chars.count, unicodeString: &utf16Chars)
1649
1708
  keyDown?.post(tap: .cghidEventTap)
1650
1709
  let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false)
1651
- keyUp?.keyboardSetUnicodeString(stringLength: 1, unicodeString: &char)
1652
1710
  keyUp?.post(tap: .cghidEventTap)
1711
+ if charDelay > 0 {
1712
+ Thread.sleep(forTimeInterval: charDelay)
1713
+ }
1653
1714
  }
1654
1715
  }
1655
1716
 
@@ -1 +1 @@
1
- let BAEPSAE_VERSION = "6.2.0"
1
+ let BAEPSAE_VERSION = "6.3.0"