203 lines
6.2 KiB
Swift
203 lines
6.2 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import CoreGraphics
|
|
import ApplicationServices
|
|
|
|
enum SkrybeError: LocalizedError {
|
|
case invalidUsage(String)
|
|
case unsupportedSubcommand(String)
|
|
case unsupportedQuotesOption(String)
|
|
case apiURLInvalid
|
|
case apiUnreachable(String)
|
|
case invalidHTTPStatus(Int)
|
|
case invalidResponse
|
|
case pasteboardWriteFailed
|
|
case accessibilityPermissionMissing
|
|
case eventCreationFailed
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidUsage(let message):
|
|
return message
|
|
case .unsupportedSubcommand(let command):
|
|
return "Unsupported subcommand: \(command)"
|
|
case .unsupportedQuotesOption(let option):
|
|
return "Unsupported quotes option: \(option)"
|
|
case .apiURLInvalid:
|
|
return "Invalid quotes API URL."
|
|
case .apiUnreachable(let reason):
|
|
return "Unable to reach quotes API: \(reason)"
|
|
case .invalidHTTPStatus(let status):
|
|
return "Quotes API returned HTTP status \(status)."
|
|
case .invalidResponse:
|
|
return "Quotes API returned an invalid response format."
|
|
case .pasteboardWriteFailed:
|
|
return "Failed to write quote text to the macOS pasteboard."
|
|
case .accessibilityPermissionMissing:
|
|
return "Accessibility permission is required to simulate paste (Cmd+V). Enable it in System Settings > Privacy & Security > Accessibility."
|
|
case .eventCreationFailed:
|
|
return "Unable to create keyboard events for paste operation."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct QuoteResponse: Decodable {
|
|
let quote: String
|
|
let author: String
|
|
}
|
|
|
|
struct QuoteService {
|
|
private let endpoint = "http://localhost:3001/quote"
|
|
|
|
func fetchRandomQuote(timeout: TimeInterval = 5.0) throws -> QuoteResponse {
|
|
guard let url = URL(string: endpoint) else {
|
|
throw SkrybeError.apiURLInvalid
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = timeout
|
|
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
var responseData: Data?
|
|
var response: URLResponse?
|
|
var requestError: Error?
|
|
|
|
let task = URLSession.shared.dataTask(with: request) { data, urlResponse, error in
|
|
responseData = data
|
|
response = urlResponse
|
|
requestError = error
|
|
semaphore.signal()
|
|
}
|
|
|
|
task.resume()
|
|
_ = semaphore.wait(timeout: .now() + timeout + 1.0)
|
|
|
|
if let requestError {
|
|
throw SkrybeError.apiUnreachable(requestError.localizedDescription)
|
|
}
|
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw SkrybeError.invalidResponse
|
|
}
|
|
|
|
guard (200...299).contains(http.statusCode) else {
|
|
throw SkrybeError.invalidHTTPStatus(http.statusCode)
|
|
}
|
|
|
|
guard let responseData else {
|
|
throw SkrybeError.invalidResponse
|
|
}
|
|
|
|
do {
|
|
let decoded = try JSONDecoder().decode(QuoteResponse.self, from: responseData)
|
|
return decoded
|
|
} catch {
|
|
throw SkrybeError.invalidResponse
|
|
}
|
|
}
|
|
}
|
|
|
|
struct QuotePaster {
|
|
func paste(_ text: String) throws {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
|
|
guard pasteboard.setString(text, forType: .string) else {
|
|
throw SkrybeError.pasteboardWriteFailed
|
|
}
|
|
|
|
guard AXIsProcessTrusted() else {
|
|
throw SkrybeError.accessibilityPermissionMissing
|
|
}
|
|
|
|
try sendCommandV()
|
|
}
|
|
|
|
private func sendCommandV() throws {
|
|
guard
|
|
let source = CGEventSource(stateID: .hidSystemState),
|
|
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true), // 9 = 'v'
|
|
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false)
|
|
else {
|
|
throw SkrybeError.eventCreationFailed
|
|
}
|
|
|
|
keyDown.flags = .maskCommand
|
|
keyUp.flags = .maskCommand
|
|
|
|
keyDown.post(tap: .cghidEventTap)
|
|
keyUp.post(tap: .cghidEventTap)
|
|
}
|
|
}
|
|
|
|
struct SkrybeCLI {
|
|
private let quoteService = QuoteService()
|
|
private let quotePaster = QuotePaster()
|
|
|
|
func run(arguments: [String]) throws {
|
|
guard arguments.count >= 2 else {
|
|
throw SkrybeError.invalidUsage(usage())
|
|
}
|
|
|
|
let command = arguments[1]
|
|
switch command {
|
|
case "quotes":
|
|
try runQuotes(arguments: Array(arguments.dropFirst(2)))
|
|
case "--help", "-h", "help":
|
|
print(usage())
|
|
default:
|
|
throw SkrybeError.unsupportedSubcommand(command)
|
|
}
|
|
}
|
|
|
|
private func runQuotes(arguments: [String]) throws {
|
|
guard let option = arguments.first else {
|
|
throw SkrybeError.invalidUsage(quotesUsage())
|
|
}
|
|
|
|
switch option {
|
|
case "--paste":
|
|
let quote = try quoteService.fetchRandomQuote()
|
|
let rendered = "\(quote.quote) — \(quote.author)"
|
|
try quotePaster.paste(rendered)
|
|
print("✅ Pasted quote at cursor position")
|
|
case "--list", "--add":
|
|
throw SkrybeError.invalidUsage("\(option) is not implemented yet. Use `skrybe quotes --paste`.")
|
|
case "--help", "-h", "help":
|
|
print(quotesUsage())
|
|
default:
|
|
throw SkrybeError.unsupportedQuotesOption(option)
|
|
}
|
|
}
|
|
|
|
private func usage() -> String {
|
|
return """
|
|
Usage:
|
|
skrybe quotes --paste
|
|
skrybe quotes --list (planned)
|
|
skrybe quotes --add (planned)
|
|
"""
|
|
}
|
|
|
|
private func quotesUsage() -> String {
|
|
return """
|
|
Quotes subcommand:
|
|
skrybe quotes --paste Fetch random quote from localhost:3001/quote and paste at current cursor
|
|
skrybe quotes --list Planned
|
|
skrybe quotes --add Planned
|
|
"""
|
|
}
|
|
}
|
|
|
|
do {
|
|
try SkrybeCLI().run(arguments: CommandLine.arguments)
|
|
} catch {
|
|
if let localized = error as? LocalizedError, let message = localized.errorDescription {
|
|
fputs("❌ \(message)\n", stderr)
|
|
} else {
|
|
fputs("❌ \(error.localizedDescription)\n", stderr)
|
|
}
|
|
exit(EXIT_FAILURE)
|
|
}
|