| 1 | #if DEBUG |
| 2 | import UIKit |
| 3 | |
| 4 | private var scriptedPage = false |
| 5 | |
| 6 | extension 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 | |
| 158 | extension 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. |
| 187 | final 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 |