1#if DEBUG
2import UIKit
3
4private var scriptedPage = false
5
6extension CanvasView {
7 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
8 /// input without a finger: steps joined by `|`, such as
9 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
10 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`
13 /// is the Select All command; `pencil:x,y;x,y;…` draws through points as the Pencil does,
14 /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the
15 /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes
16 /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done`
17 /// ends editing, `reading` turns the reading view on or off, `record:audio` or
18 /// `record:video` records, `pauserecording` pauses or resumes and `stoprecording` stops it,
19 /// `wait:N` waits N seconds more before the next step, `tree` saves the view hierarchy to
20 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
21 /// screenshot command; `snap:a` only logs, for a simulator screenshot with the keyboard;
22 /// `activate:Label` activates the element of any window so named, as VoiceOver would,
23 /// opening a button's menu, and `audit` logs whether the format bar and tag grid name every
24 /// button.
25 func runScript() {
26 // Only the first page shown runs it, not every page opened afterwards.
27 guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
28 scriptedPage = true
29 // UIKit's accessibility, as VoiceOver or a UI test loads it, for `activate` and `audit`.
30 if let library = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW),
31 let enable = dlsym(library, "_AXSSetAutomationEnabled")
32 {
33 unsafeBitCast(enable, to: (@convention(c) (Int32) -> Void).self)(1)
34 }
35 var delay = 1.0
36 for step in script.split(separator: "|") {
37 if step.hasPrefix("wait:") { delay += Double(step.dropFirst(5)) ?? 0 }
38 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
39 self?.perform(String(step))
40 }
41 delay += 0.4
42 }
43 }
44
45 private func perform(_ step: String) {
46 let (command, argument) = step.firstIndex(of: ":").map {
47 (String(step[..<$0]), String(step[step.index(after: $0)...]))
48 } ?? (step, "")
49 NSLog("script \(command) \(argument)")
50 switch command {
51 case "tap":
52 let values = argument.split(separator: ",").compactMap { Double($0) }
53 tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
54 case "pencil":
55 // Points as `x,y`, or `x,y,pressure` for the Pencil's pressure from 0 to 1.
56 let points = argument.split(separator: ";").map {
57 let values = $0.split(separator: ",").compactMap { Double($0) }
58 let point = CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)
59 return (point, values.count > 2 ? Float(values[2]) : -1) as InkSample
60 }
61 inkPressed(points[0])
62 inkMoved(points.dropFirst())
63 inkReleased()
64 case "tool":
65 let parts = argument.split(separator: ":")
66 switch parts.first {
67 case "pen": setInkTool(.pen(UInt8(parts.last ?? "") ?? 0))
68 case "eraser": setInkTool(.eraser)
69 case "lasso": setInkTool(.lasso)
70 case "shape": setInkTool(.shape(UInt8(parts.last ?? "") ?? 0))
71 default: break
72 }
73 case "picker": showPicker(!pickerShown)
74 case "pencildoubletap": pencilInteractionDidTap(UIPencilInteraction())
75 case "deleteink": deleteInk()
76 case "type": insertText(argument)
77 case "return": insertText("\n")
78 case "delete": deleteBackward()
79 case "mark": setMarkedText(argument, selectedRange: NSRange(location: argument.utf16.count, length: 0))
80 case "unmark": unmarkText()
81 case "select":
82 let values = argument.split(separator: ",").compactMap { Int($0) }
83 // As a gesture would, the change reaches the system's text interaction.
84 inputDelegate?.selectionWillChange(self)
85 defer { inputDelegate?.selectionDidChange(self) }
86 selectedTextRange = textRange(
87 from: position(from: beginningOfDocument, offset: values[0])!,
88 to: position(from: beginningOfDocument, offset: values[1])!)
89 case "scroll":
90 // `scroll:,Y` keeps the horizontal offset.
91 let values = argument.split(separator: ",", omittingEmptySubsequences: false).map { Double($0) }
92 setContentOffset(CGPoint(x: values[0] ?? contentOffset.x, y: values[1] ?? contentOffset.y), animated: true)
93 case "zoom": setZoomScale(Double(argument) ?? 1, animated: false)
94 case "doubletap":
95 let values = argument.split(separator: ",").compactMap { Double($0) }
96 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
97 case "rotate":
98 let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight
99 window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
100 case "menu":
101 guard let range = selectedTextRange,
102 let menu = interactions.lazy.compactMap({ $0 as? UIEditMenuInteraction }).first(where: { $0.delegate === self })
103 else { break }
104 let caret = caretRect(for: range.start)
105 menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY)))
106 case "done": _ = resignFirstResponder()
107 case "wait", "snap": break
108 case "activate":
109 guard let found = (window?.windowScene?.windows ?? []).reversed().lazy
110 .compactMap({ Self.element(argument, in: $0) }).first
111 else {
112 NSLog("script activate: no \(argument)")
113 break
114 }
115 if let button = found as? UIButton, button.showsMenuAsPrimaryAction, let menu = button.contextMenuInteraction {
116 let middle = CGPoint(x: button.bounds.midX, y: button.bounds.midY)
117 menu.perform(Selector(("_presentMenuAtLocation:")), with: NSValue(cgPoint: middle))
118 } else if !found.accessibilityActivate(), let control = found as? UIControl {
119 control.sendActions(for: .primaryActionTriggered)
120 }
121 case "audit": audit()
122 case "record", "stoprecording":
123 let page = sequence(first: self as UIResponder, next: \.next).lazy.compactMap { $0 as? PageViewController }.first
124 if command == "record" { page?.record(video: argument == "video") } else { page?.stopRecording() }
125 case "pauserecording": Recorder.current.map { $0.pause(!($0.elapsed?.paused ?? false)) }
126 case "selectall": selectAll(nil)
127 case "reading": showReading(!readingShown)
128 case "format": apply(UInt8(argument) ?? 0)
129 case "find": _ = find(argument)
130 case "title": _ = focusTitle()
131 case "picture":
132 let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 200)).image { context in
133 UIColor.systemTeal.setFill()
134 context.fill(CGRect(x: 0, y: 0, width: 320, height: 200))
135 UIColor.systemYellow.setFill()
136 context.cgContext.fillEllipse(in: CGRect(x: 110, y: 50, width: 100, height: 100))
137 }
138 if let data = image.jpegData(compressionQuality: 0.9) { insertPicture(data, size: CGSize(width: 240, height: 150)) }
139 case "tree":
140 let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
141 try? "\(value(forKey: "recursiveDescription") ?? "")".write(
142 to: documents.appendingPathComponent("tree.txt"), atomically: true, encoding: .utf8)
143 case "shot":
144 guard let window else { break }
145 let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
146 window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
147 }
148 let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
149 try? image.pngData()?.write(to: documents.appendingPathComponent("\(argument).png"))
150 default: NSLog("script: unknown step \(step)")
151 }
152 if let range = selectedTextRange, let all = textRange(from: beginningOfDocument, to: endOfDocument) {
153 NSLog("script selection \(offset(from: beginningOfDocument, to: range.start))..\(offset(from: beginningOfDocument, to: range.end)) text \(text(in: all) ?? "nil")")
154 }
155 }
156}
157
158extension CanvasView {
159 /// The first view or accessibility element under `root` VoiceOver names `label`.
160 fileprivate static func element(_ label: String, in root: NSObject) -> NSObject? {
161 if let view = root as? UIView, view.isHidden { return nil }
162 if root.isAccessibilityElement, root.accessibilityLabel == label { return root }
163 let children = ((root as? UIView)?.subviews ?? []) + (root.accessibilityElements as? [NSObject] ?? [])
164 return children.lazy.compactMap { element(label, in: $0) }.first
165 }
166
167 /// Logs `audit ok`, or each button of the format bar and tag grid VoiceOver can't name.
168 fileprivate func audit() {
169 func buttons(_ view: UIView) -> [UIButton] {
170 (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons)
171 }
172 var problems = buttons(formatBar).filter { ($0.accessibilityLabel ?? "").isEmpty }.map { "unnamed \($0)" }
173 if let grid = TagGrid.shown {
174 let named = buttons(grid).filter {
175 $0.isAccessibilityElement && $0.accessibilityTraits.contains(.button) && !($0.accessibilityLabel ?? "").isEmpty
176 }.compactMap(\.accessibilityLabel)
177 let expected = Tags.all.prefix(Tags.keyed).map(\.name) + ["More Tags…", "Remove Tag"]
178 if named != expected { problems.append("grid names \(named)") }
179 if !grid.accessibilityViewIsModal { problems.append("grid not modal") }
180 }
181 NSLog(problems.isEmpty ? "audit ok, grid \(TagGrid.shown != nil)" : "audit FAIL \(problems)")
182 }
183}
184
185/// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a
186/// file, to check that saving tells file providers about it.
187final class Presenter: NSObject, NSFilePresenter {
188 private static var watching: Presenter?
189 let presentedItemURL: URL?
190 let presentedItemOperationQueue = OperationQueue()
191
192 private init(_ url: URL) { presentedItemURL = url }
193
194 static func watch() {
195 guard let path = ProcessInfo.processInfo.environment["SNOWBOUND_PRESENTER"] else { return }
196 let presenter = Presenter(URL(fileURLWithPath: path))
197 NSFileCoordinator.addFilePresenter(presenter)
198 watching = presenter
199 }
200
201 func presentedItemDidChange() { NSLog("presenter changed") }
202}
203#endif