Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c276ac88a0 | ||
|
|
d800d4239e | ||
|
|
fbb2a577c9 | ||
|
|
b6df8b09e5 | ||
|
|
931e59e80c | ||
|
|
16ce92bf2b | ||
|
|
1a0c312e39 | ||
|
|
761ae3048f | ||
|
|
289d403c53 | ||
|
|
b63ee9a326 | ||
|
|
7b38b16b82 |
42
.vscode/launch.json
vendored
42
.vscode/launch.json
vendored
@ -1,22 +1,22 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:AXorcist}",
|
||||
"name": "Debug axorc",
|
||||
"program": "${workspaceFolder:AXorcist}/.build/debug/axorc",
|
||||
"preLaunchTask": "swift: Build Debug axorc"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:AXorcist}",
|
||||
"name": "Release axorc",
|
||||
"program": "${workspaceFolder:AXorcist}/.build/release/axorc",
|
||||
"preLaunchTask": "swift: Build Release axorc"
|
||||
}
|
||||
]
|
||||
}
|
||||
"configurations": [
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:AXorcist}",
|
||||
"name": "Debug axorc",
|
||||
"program": "${workspaceFolder:AXorcist}/.build/debug/axorc",
|
||||
"preLaunchTask": "swift: Build Debug axorc"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:AXorcist}",
|
||||
"name": "Release axorc",
|
||||
"program": "${workspaceFolder:AXorcist}/.build/release/axorc",
|
||||
"preLaunchTask": "swift: Build Release axorc"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to AXorcist will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Printable ASCII typing now uses physical key events before falling back to Unicode events, improving reliability in VM and headless launch paths.
|
||||
- Unsupported command dispatch now returns an `unknown_command` error instead of trapping, and JSON path hint parsing no longer writes warnings to stdout for unknown attributes.
|
||||
- Preserve CFRange-backed AXValue attributes such as selected text ranges instead of misclassifying raw value 4 as a boolean. Thanks @WinnCook.
|
||||
|
||||
## [0.1.2] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
- Avoid treating SwiftPM's `.build/checkouts` cache as a vendored workspace when resolving Commander.
|
||||
|
||||
## [0.1.1] - 2026-04-28
|
||||
|
||||
### Changed
|
||||
- Prefer a vendored local Commander checkout when present, while keeping the external release dependency exact.
|
||||
- Refresh SwiftLog dependency pins.
|
||||
|
||||
## [0.1.0] - 2026-01-18
|
||||
|
||||
### Added
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "619e995a786edef531957047f8309989d97da5524fd56921d038f3677c822488",
|
||||
"originHash" : "903d57ca4ce1a2cffdc42cc8f73c40c751397fa74948f20679efc3176edb4196",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
|
||||
"version" : "1.8.0"
|
||||
"revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
|
||||
"version" : "1.12.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// swift-tools-version: 6.2
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import Foundation
|
||||
import PackageDescription
|
||||
|
||||
let approachableConcurrencySettings: [SwiftSetting] = [
|
||||
@ -10,6 +11,16 @@ let approachableConcurrencySettings: [SwiftSetting] = [
|
||||
.defaultIsolation(MainActor.self),
|
||||
]
|
||||
|
||||
let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
let localCommanderPath = packageDirectory.deletingLastPathComponent().appendingPathComponent("Commander").path
|
||||
let isSwiftPMCheckout = packageDirectory.path.contains("/.build/checkouts/")
|
||||
let commanderDependency: Package.Dependency =
|
||||
if !isSwiftPMCheckout, FileManager.default.fileExists(atPath: localCommanderPath) {
|
||||
.package(path: "../Commander")
|
||||
} else {
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.2")
|
||||
}
|
||||
|
||||
let package = Package(
|
||||
name: "axPackage", // Renamed package slightly to avoid any confusion with executable name
|
||||
platforms: [
|
||||
@ -20,7 +31,7 @@ let package = Package(
|
||||
.executable(name: "axorc", targets: ["axorc"]), // Product 'axorc' comes from target 'axorc'
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
commanderDependency,
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# AXorcist 🧙♂️ - The power of Swift compels your UI to obey!
|
||||
|
||||

|
||||
|
||||
<p align="center">
|
||||
<img src="assets/logo.png" alt="AXorcist Logo">
|
||||
</p>
|
||||
|
||||
@ -198,7 +198,9 @@ public class AXorcist {
|
||||
case let .observe(observeCommand):
|
||||
handleObserve(command: observeCommand)
|
||||
default:
|
||||
fatalError("Unsupported command type: \(envelope.command)")
|
||||
.errorResponse(
|
||||
message: "Unsupported command type: \(envelope.command.type)",
|
||||
code: .unknownCommand)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -175,14 +175,110 @@ extension Element {
|
||||
try self.typeCharacter(character)
|
||||
}
|
||||
|
||||
if delay > 0 {
|
||||
Thread.sleep(forTimeInterval: delay)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: delay > 0 ? delay : 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
/// Type a single character
|
||||
@MainActor public static func typeCharacter(_ character: Character) throws {
|
||||
// Physical key events survive VM/headless launch paths that can silently drop Unicode-only events.
|
||||
if let stroke = self.keyboardStroke(for: character) {
|
||||
try self.postKeyboardStroke(stroke)
|
||||
return
|
||||
}
|
||||
|
||||
try self.postUnicodeCharacter(character)
|
||||
}
|
||||
|
||||
static func keyboardStroke(for character: Character) -> (keyCode: CGKeyCode, flags: CGEventFlags)? {
|
||||
let string = String(character)
|
||||
guard string.count == 1 else { return nil }
|
||||
|
||||
if let scalar = string.unicodeScalars.first,
|
||||
CharacterSet.lowercaseLetters.contains(scalar),
|
||||
let key = SpecialKey(rawValue: string),
|
||||
let keyCode = key.keyCode
|
||||
{
|
||||
return (keyCode, [])
|
||||
}
|
||||
|
||||
if let scalar = string.unicodeScalars.first,
|
||||
CharacterSet.uppercaseLetters.contains(scalar),
|
||||
let key = SpecialKey(rawValue: string.lowercased()),
|
||||
let keyCode = key.keyCode
|
||||
{
|
||||
return (keyCode, .maskShift)
|
||||
}
|
||||
|
||||
if let key = SpecialKey(rawValue: string),
|
||||
let keyCode = key.keyCode
|
||||
{
|
||||
return (keyCode, [])
|
||||
}
|
||||
|
||||
let shiftedSymbols: [Character: CGKeyCode] = [
|
||||
"!": 18,
|
||||
"@": 19,
|
||||
"#": 20,
|
||||
"$": 21,
|
||||
"%": 23,
|
||||
"^": 22,
|
||||
"&": 26,
|
||||
"*": 28,
|
||||
"(": 25,
|
||||
")": 29,
|
||||
"_": 27,
|
||||
"+": 24,
|
||||
"{": 33,
|
||||
"}": 30,
|
||||
"|": 42,
|
||||
":": 41,
|
||||
"\"": 39,
|
||||
"<": 43,
|
||||
">": 47,
|
||||
"?": 44,
|
||||
"~": 50,
|
||||
]
|
||||
if let keyCode = shiftedSymbols[character] {
|
||||
return (keyCode, .maskShift)
|
||||
}
|
||||
|
||||
let symbols: [Character: CGKeyCode] = [
|
||||
" ": 49,
|
||||
"-": 27,
|
||||
"=": 24,
|
||||
"[": 33,
|
||||
"]": 30,
|
||||
"\\": 42,
|
||||
";": 41,
|
||||
"'": 39,
|
||||
",": 43,
|
||||
".": 47,
|
||||
"/": 44,
|
||||
"`": 50,
|
||||
]
|
||||
if let keyCode = symbols[character] {
|
||||
return (keyCode, [])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func postKeyboardStroke(_ stroke: (keyCode: CGKeyCode, flags: CGEventFlags)) throws {
|
||||
guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: stroke.keyCode, keyDown: true),
|
||||
let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: stroke.keyCode, keyDown: false)
|
||||
else {
|
||||
throw UIAutomationError.failedToCreateEvent
|
||||
}
|
||||
|
||||
keyDown.flags = stroke.flags
|
||||
keyUp.flags = stroke.flags
|
||||
keyDown.post(tap: .cghidEventTap)
|
||||
Thread.sleep(forTimeInterval: 0.001)
|
||||
keyUp.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
private static func postUnicodeCharacter(_ character: Character) throws {
|
||||
let string = String(character)
|
||||
|
||||
// Create keyboard event
|
||||
@ -236,22 +332,22 @@ extension Element {
|
||||
|
||||
/// Perform a hotkey combination
|
||||
@MainActor public static func performHotkey(keys: [String], holdDuration: TimeInterval = 0.1) throws {
|
||||
var modifiers: CGEventFlags = []
|
||||
var modifiers: [(keyCode: CGKeyCode?, flag: CGEventFlags)] = []
|
||||
var mainKey: SpecialKey?
|
||||
|
||||
// Parse keys
|
||||
for key in keys {
|
||||
switch key.lowercased() {
|
||||
case "cmd", "command":
|
||||
modifiers.insert(.maskCommand)
|
||||
modifiers.append((keyCode: 0x37, flag: .maskCommand))
|
||||
case "shift":
|
||||
modifiers.insert(.maskShift)
|
||||
modifiers.append((keyCode: 0x38, flag: .maskShift))
|
||||
case "option", "opt", "alt":
|
||||
modifiers.insert(.maskAlternate)
|
||||
modifiers.append((keyCode: 0x3A, flag: .maskAlternate))
|
||||
case "ctrl", "control":
|
||||
modifiers.insert(.maskControl)
|
||||
modifiers.append((keyCode: 0x3B, flag: .maskControl))
|
||||
case "fn", "function":
|
||||
modifiers.insert(.maskSecondaryFn)
|
||||
modifiers.append((keyCode: nil, flag: .maskSecondaryFn))
|
||||
default:
|
||||
// Try to parse as special key
|
||||
if let special = SpecialKey(rawValue: key.lowercased()) {
|
||||
@ -269,11 +365,42 @@ extension Element {
|
||||
throw UIAutomationError.invalidHotkey(keys.joined(separator: "+"))
|
||||
}
|
||||
|
||||
// Type the key with modifiers
|
||||
try self.typeKey(key, modifiers: modifiers)
|
||||
guard let mainKeyCode = key.keyCode else {
|
||||
throw UIAutomationError.unsupportedKey(key.rawValue)
|
||||
}
|
||||
|
||||
// Hold for specified duration
|
||||
Thread.sleep(forTimeInterval: holdDuration)
|
||||
func makeKeyboardEvent(keyCode: CGKeyCode, keyDown: Bool, flags: CGEventFlags) throws -> CGEvent {
|
||||
guard let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: keyDown) else {
|
||||
throw UIAutomationError.failedToCreateEvent
|
||||
}
|
||||
event.flags = flags
|
||||
return event
|
||||
}
|
||||
|
||||
func postKeyboardEvent(keyCode: CGKeyCode, keyDown: Bool, flags: CGEventFlags) throws {
|
||||
try makeKeyboardEvent(keyCode: keyCode, keyDown: keyDown, flags: flags).post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
var activeFlags: CGEventFlags = []
|
||||
for modifier in modifiers {
|
||||
activeFlags.insert(modifier.flag)
|
||||
if let keyCode = modifier.keyCode {
|
||||
try postKeyboardEvent(keyCode: keyCode, keyDown: true, flags: activeFlags)
|
||||
}
|
||||
}
|
||||
|
||||
try postKeyboardEvent(keyCode: mainKeyCode, keyDown: true, flags: activeFlags)
|
||||
if holdDuration > 0 {
|
||||
Thread.sleep(forTimeInterval: holdDuration)
|
||||
}
|
||||
try postKeyboardEvent(keyCode: mainKeyCode, keyDown: false, flags: activeFlags)
|
||||
|
||||
for modifier in modifiers.reversed() {
|
||||
activeFlags.remove(modifier.flag)
|
||||
if let keyCode = modifier.keyCode {
|
||||
try postKeyboardEvent(keyCode: keyCode, keyDown: false, flags: activeFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# ``AXorcist``
|
||||
# `AXorcist`
|
||||
|
||||
A powerful Swift framework for macOS accessibility automation.
|
||||
|
||||
@ -32,31 +32,31 @@ if hasPermissions {
|
||||
|
||||
### Essentials
|
||||
|
||||
- ``AXorcist``
|
||||
- ``AXPermissionHelpers``
|
||||
- ``AXCommandEnvelope``
|
||||
- ``AXResponse``
|
||||
- `AXorcist`
|
||||
- `AXPermissionHelpers`
|
||||
- `AXCommandEnvelope`
|
||||
- `AXResponse`
|
||||
|
||||
### Commands
|
||||
|
||||
- ``AXCommand``
|
||||
- ``AXQueryCommand``
|
||||
- ``AXActionCommand``
|
||||
- ``AXGetAttributesCommand``
|
||||
- `AXCommand`
|
||||
- `AXQueryCommand`
|
||||
- `AXActionCommand`
|
||||
- `AXGetAttributesCommand`
|
||||
|
||||
### Elements
|
||||
|
||||
- ``Element``
|
||||
- ``ElementSearch``
|
||||
- ``SearchCriteria``
|
||||
- `Element`
|
||||
- `ElementSearch`
|
||||
- `SearchCriteria`
|
||||
|
||||
### Utilities
|
||||
|
||||
- ``GlobalAXLogger``
|
||||
- ``AXLogEntry``
|
||||
- ``ProcessUtils``
|
||||
- `GlobalAXLogger`
|
||||
- `AXLogEntry`
|
||||
- `ProcessUtils`
|
||||
|
||||
### Error Handling
|
||||
|
||||
- ``AccessibilityError``
|
||||
- ``AXError``
|
||||
- `AccessibilityError`
|
||||
- `AXError`
|
||||
|
||||
@ -67,13 +67,7 @@ public struct JSONPathHintComponent: Codable, Sendable {
|
||||
|
||||
/// Converts this component to a simple criteria dictionary for use with existing matching logic.
|
||||
public var simpleCriteria: [String: String]? {
|
||||
guard let resolvedAttributeName = axAttributeName else {
|
||||
// Log a warning here if this component is used, as it means an invalid attribute type was provided.
|
||||
// GlobalAXLogger.shared.log(...) or axWarningLog(...) - Requires importing/access
|
||||
// For now, just return nil. The calling code should handle this.
|
||||
print("WARNING: JSONPathHintComponent has unrecognized attribute type: \(self.attribute)")
|
||||
return nil
|
||||
}
|
||||
guard let resolvedAttributeName = axAttributeName else { return nil }
|
||||
return [resolvedAttributeName: self.value]
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,8 @@
|
||||
//
|
||||
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
import Foundation
|
||||
#if canImport(CoreGraphics)
|
||||
import CoreGraphics // Added for CGWindowListCopyWindowInfo
|
||||
#endif
|
||||
@ -49,12 +47,7 @@ public struct RunningApplicationHelper {
|
||||
|
||||
/// Get the current application
|
||||
public static var currentApplication: NSRunningApplication {
|
||||
#if canImport(AppKit)
|
||||
return NSRunningApplication.current
|
||||
#else
|
||||
// Fallback - create a minimal implementation
|
||||
fatalError("NSRunningApplication.current not available on this platform")
|
||||
#endif
|
||||
NSRunningApplication.current
|
||||
}
|
||||
|
||||
/// Get the current application's process info
|
||||
|
||||
@ -68,15 +68,8 @@ enum ValueUnwrapper {
|
||||
""".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
axDebugLog(message)
|
||||
|
||||
// Handle special boolean type
|
||||
if axValueType.rawValue == 4 { // kAXValueBooleanType (private)
|
||||
var boolResult: DarwinBoolean = false
|
||||
if AXValueGetValue(axVal, axValueType, &boolResult) {
|
||||
return boolResult.boolValue
|
||||
}
|
||||
}
|
||||
|
||||
// Use new AXValue extensions for cleaner unwrapping
|
||||
// AXValueType.cfRange also uses raw value 4, so raw-value guesses can corrupt
|
||||
// range-based attributes like selectedTextRange into booleans.
|
||||
let unwrappedExtensionValue = axVal.value()
|
||||
let valueDescription = String(describing: unwrappedExtensionValue)
|
||||
let returnMessage = "ValueUnwrapper.unwrapAXValue: axVal.value() returned: \(valueDescription) " +
|
||||
|
||||
@ -18,4 +18,39 @@ struct InputDriverTests {
|
||||
// If running in CI without UI, location may be nil; just assert cache mirrors result.
|
||||
#expect(cache == InputDriver.currentLocation())
|
||||
}
|
||||
|
||||
@Test("keyboardStroke maps printable ASCII to physical key events")
|
||||
@MainActor
|
||||
func keyboardStrokeMapsPrintableASCII() throws {
|
||||
let lower = try #require(Element.keyboardStroke(for: "a"))
|
||||
#expect(lower.keyCode == 0)
|
||||
#expect(!lower.flags.contains(.maskShift))
|
||||
|
||||
let upper = try #require(Element.keyboardStroke(for: "A"))
|
||||
#expect(upper.keyCode == 0)
|
||||
#expect(upper.flags.contains(.maskShift))
|
||||
|
||||
let digit = try #require(Element.keyboardStroke(for: "1"))
|
||||
#expect(digit.keyCode == 18)
|
||||
#expect(!digit.flags.contains(.maskShift))
|
||||
|
||||
let symbol = try #require(Element.keyboardStroke(for: "!"))
|
||||
#expect(symbol.keyCode == 18)
|
||||
#expect(symbol.flags.contains(.maskShift))
|
||||
|
||||
let hyphen = try #require(Element.keyboardStroke(for: "-"))
|
||||
#expect(hyphen.keyCode == 27)
|
||||
#expect(!hyphen.flags.contains(.maskShift))
|
||||
|
||||
let underscore = try #require(Element.keyboardStroke(for: "_"))
|
||||
#expect(underscore.keyCode == 27)
|
||||
#expect(underscore.flags.contains(.maskShift))
|
||||
}
|
||||
|
||||
@Test("keyboardStroke leaves non-ASCII for Unicode fallback")
|
||||
@MainActor
|
||||
func keyboardStrokeLeavesNonASCIIForUnicodeFallback() {
|
||||
#expect(Element.keyboardStroke(for: "é") == nil)
|
||||
#expect(Element.keyboardStroke(for: "🙂") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
private final class PipeStreamBuffer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(
|
||||
label: "axorcist.tests.pipe-stream.\(UUID().uuidString)",
|
||||
qos: .userInitiated)
|
||||
nonisolated(unsafe) private var data = Data()
|
||||
|
||||
nonisolated func append(_ chunk: Data) {
|
||||
self.queue.async {
|
||||
self.data.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func snapshot() -> Data {
|
||||
self.queue.sync { self.data }
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func startStreaming(pipe: Pipe) -> () -> Data {
|
||||
let queue = DispatchQueue(label: "axorcist.tests.pipe-stream.\(UUID().uuidString)", qos: .userInitiated)
|
||||
var collected = Data()
|
||||
let buffer = PipeStreamBuffer()
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
@ -14,13 +30,11 @@ func startStreaming(pipe: Pipe) -> () -> Data {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
queue.async {
|
||||
collected.append(chunk)
|
||||
}
|
||||
buffer.append(chunk)
|
||||
}
|
||||
|
||||
return {
|
||||
group.wait()
|
||||
return queue.sync { collected }
|
||||
return buffer.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
24
Tests/AXorcistTests/ValueUnwrapperTests.swift
Normal file
24
Tests/AXorcistTests/ValueUnwrapperTests.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import ApplicationServices
|
||||
import Testing
|
||||
@testable import AXorcist
|
||||
|
||||
@Suite("ValueUnwrapper Tests", .tags(.safe))
|
||||
struct ValueUnwrapperTests {
|
||||
@MainActor
|
||||
@Test("unwrap preserves CFRange AXValue", .tags(.safe))
|
||||
func unwrapPreservesCFRangeAXValue() {
|
||||
let expected = CFRange(location: 12, length: 34)
|
||||
guard let axValue = AXValue.create(range: expected) else {
|
||||
Issue.record("Failed to create AXValue from CFRange")
|
||||
return
|
||||
}
|
||||
|
||||
guard let actual = ValueUnwrapper.unwrap(axValue) as? CFRange else {
|
||||
Issue.record("Expected ValueUnwrapper to return a CFRange")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(actual.location == expected.location)
|
||||
#expect(actual.length == expected.length)
|
||||
}
|
||||
}
|
||||
BIN
docs/assets/readme-banner.jpg
Normal file
BIN
docs/assets/readme-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Loading…
Reference in New Issue
Block a user