Files
byre/hands/Sources/byre-hands/Screen.swift
T

153 lines
6.4 KiB
Swift

import AppKit
import CoreGraphics
import ImageIO
import ScreenCaptureKit
import UniformTypeIdentifiers
struct WindowInfo {
let id: CGWindowID
let pid: pid_t
let app: String
let title: String
let frame: CGRect
}
func windowInfos(_ list: [[String: Any]]) -> [WindowInfo] {
return list.compactMap { w in
guard (w[kCGWindowLayer as String] as? Int) == 0,
let id = w[kCGWindowNumber as String] as? Int,
let pid = w[kCGWindowOwnerPID as String] as? Int,
let b = w[kCGWindowBounds as String] as? NSDictionary,
let frame = CGRect(dictionaryRepresentation: b),
frame.width > 1, frame.height > 1
else {
return nil
}
return WindowInfo(id: CGWindowID(id), pid: pid_t(pid), app: w[kCGWindowOwnerName as String] as? String ?? "", title: w[kCGWindowName as String] as? String ?? "", frame: frame)
}
}
func onScreenWindows() -> [WindowInfo] {
let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
return windowInfos(list)
}
func window(_ id: Int) throws -> WindowInfo {
guard let wid = CGWindowID(exactly: id) else {
throw ToolError("no window \(id); screen_list shows the current ones")
}
let list = CGWindowListCopyWindowInfo(.optionIncludingWindow, wid) as? [[String: Any]] ?? []
guard let w = windowInfos(list).first else {
throw ToolError("no window \(id); screen_list shows the current ones")
}
return w
}
func describe(_ w: WindowInfo) -> String {
return "\(w.id) \(w.app) \(w.title.debugDescription) \(Int(w.frame.width))x\(Int(w.frame.height)) at \(Int(w.frame.minX)),\(Int(w.frame.minY))"
}
func screenList(_ args: [String: Any]) async throws -> [Content] {
var out = "apps (pid name bundle):\n"
for app in NSWorkspace.shared.runningApplications where app.activationPolicy == .regular {
out += "\(app.processIdentifier) \(app.localizedName ?? "") \(app.bundleIdentifier ?? "")\(app.isActive ? " active" : "")\(app.isHidden ? " hidden" : "")\n"
}
out += "\nwindows, front to back (id app title size at x,y):\n"
for w in onScreenWindows() {
out += describe(w) + "\n"
}
out += "\ndisplays (index size at x,y):\n"
for (i, s) in NSScreen.screens.enumerated() {
let id = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0
let b = CGDisplayBounds(id)
out += "\(i) \(Int(b.width))x\(Int(b.height)) at \(Int(b.minX)),\(Int(b.minY))\(CGDisplayIsMain(id) != 0 ? " main" : "")\n"
}
return [.text(out)]
}
struct Shot {
let png: Data
let note: String
}
func capture(window id: Int?, display: Int?, crop: [String: Any]?, maxEdge: Double) async throws -> Shot {
if !maxEdge.isFinite || maxEdge < 16 || maxEdge > 8192 {
throw ToolError("max_edge must be between 16 and 8192 pixels")
}
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
let filter: SCContentFilter
let size: CGSize
let what: String
var base = CGPoint.zero
if let id {
guard let w = content.windows.first(where: { Int($0.windowID) == id }), let app = w.owningApplication else {
throw ToolError("no window \(id) to capture; screen_list shows the current ones")
}
let center = CGPoint(x: w.frame.midX, y: w.frame.midY)
guard w.isOnScreen, let d = content.displays.first(where: { $0.frame.contains(center) }) else {
throw ToolError("window \(id) is not on screen")
}
filter = SCContentFilter(display: d, including: [app], exceptingWindows: [])
size = w.frame.size
base = CGPoint(x: w.frame.minX - d.frame.minX, y: w.frame.minY - d.frame.minY)
what = "window \(id)"
} else {
let screens = NSScreen.screens
let i = display ?? 0
guard i >= 0, i < screens.count,
let did = screens[i].deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID,
let d = content.displays.first(where: { $0.displayID == did })
else {
throw ToolError("no display \(i)")
}
filter = SCContentFilter(display: d, excludingWindows: [])
size = CGSize(width: d.width, height: d.height)
what = "display \(i)"
}
var src = CGRect(origin: .zero, size: size)
if let crop {
src = CGRect(x: crop.double("x") ?? 0, y: crop.double("y") ?? 0, width: crop.double("width") ?? size.width, height: crop.double("height") ?? size.height).intersection(src)
if src.isEmpty {
throw ToolError("crop is outside the \(Int(size.width))x\(Int(size.height)) \(what)")
}
}
let k = min(1, maxEdge / max(src.width, src.height))
let cfg = SCStreamConfiguration()
cfg.sourceRect = src.offsetBy(dx: base.x, dy: base.y)
cfg.width = max(1, Int((src.width * k).rounded()))
cfg.height = max(1, Int((src.height * k).rounded()))
cfg.showsCursor = false
let img = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: cfg)
let data = NSMutableData()
let dest = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, img, nil)
CGImageDestinationFinalize(dest)
var note = "\(what), \(Int(size.width))x\(Int(size.height)) points; image \(img.width)x\(img.height)"
if k < 1 || src.origin != .zero {
note += String(format: "; \(what) point = image pixel / %.4f", k)
if src.origin != .zero {
note += " + (\(Int(src.minX)), \(Int(src.minY)))"
}
} else {
note += ", 1 pixel = 1 point"
}
return Shot(png: data as Data, note: note)
}
func captureTool(_ args: [String: Any]) async throws -> [Content] {
let shot = try await capture(window: args.int("window"), display: args.int("display"), crop: args.dict("crop"), maxEdge: args.double("max_edge") ?? 1568)
guard args.bool("save") else {
return [.text(shot.note), .png(shot.png)]
}
return [.text("\(shot.note); saved to \(try save(shot.png))"), .png(shot.png)]
}
func save(_ png: Data) throws -> String {
guard let dir = mediaDir else {
throw ToolError("save needs byre's media folder, and this byre-hands has none")
}
let url = URL(filePath: dir).appending(path: "capture-\(UUID().uuidString.prefix(8).lowercased()).png")
try png.write(to: url)
return url.path
}