Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions Hammerspoon 2/Modules/hs.window/HSWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,45 @@ import Foundation
import JavaScriptCore
import AppKit
import AXSwift
import ScreenCaptureKit

// Expose some private API, per https://github.com/saagarjha/Ensemble/blob/27f3fd77c261660c1f469a246858d23d06aa8c1f/macOS/SPI.swift#L21
let _AXUIElementGetWindow = unsafe unsafeBitCast(dlsym(dlopen(nil, RTLD_LAZY), "_AXUIElementGetWindow"), to: (@convention(c) (AXUIElement, UnsafeMutablePointer<CGWindowID>) -> AXError)?.self)

/// Captures the current on-screen contents of the window with the given ID.
///
/// Shared by `HSWindow.snapshot()` and `HSWindowModule.snapshotForID()`.
func captureWindowSnapshot(windowID: CGWindowID, keepTransparency: Bool) -> JSPromise? {
return JSEngine.shared.createPromise { holder in
Task.detached {
do {
let content = try await SCShareableContent.current
guard let scWindow = content.windows.first(where: { $0.windowID == windowID }) else {
await holder.rejectWithMessage("hs.window.snapshot: could not locate window \(windowID)")
return
}

let filter = SCContentFilter(desktopIndependentWindow: scWindow)
let config = SCStreamConfiguration()
config.width = Int(filter.contentRect.width * Double(filter.pointPixelScale))
config.height = Int(filter.contentRect.height * Double(filter.pointPixelScale))
config.showsCursor = false
if !keepTransparency {
config.backgroundColor = CGColor.black
}

let cgImage = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config
)
await holder.resolveWith(HSImage(image: NSImage(cgImage: cgImage, size: filter.contentRect.size)))
} catch {
await holder.rejectWithMessage("hs.window.snapshot: \(error.localizedDescription)")
}
}
}
}

/// Object representing a window. You should not instantiate these directly, but rather, use the methods in hs.window to create them for you.
/// Note that this type uses private macOS APIs
@objc protocol HSWindowAPI: HSTypeAPI, JSExport {
Expand Down Expand Up @@ -192,6 +227,21 @@ let _AXUIElementGetWindow = unsafe unsafeBitCast(dlsym(dlopen(nil, RTLD_LAZY), "
/// ```
@objc func centerOnScreen()

// MARK: - Screenshot

/// Capture the current on-screen contents of this window as an image.
///
/// Requires **Screen Recording** permission.
///
/// - Parameter keepTransparency?: Whether to preserve the window's alpha channel. If `false` (the default), transparent regions are filled with an opaque black background.
/// - Returns: {Promise<HSImage>} Resolves with the captured image, or rejects if the capture fails (e.g. permission denied, or the window could no longer be located).
/// - Example:
/// ```js
/// const win = hs.window.focusedWindow()
/// win.snapshot().then(img => img.saveToFile("/tmp/window.png"))
/// ```
@objc func snapshot(_ keepTransparency: Bool) -> JSPromise?

// MARK: - Advanced

/// Get the underlying AXElement
Expand Down Expand Up @@ -468,6 +518,19 @@ let _AXUIElementGetWindow = unsafe unsafeBitCast(dlsym(dlopen(nil, RTLD_LAZY), "
position = HSPoint(x: Double(centerX), y: Double(centerY))
}

// MARK: - Screenshot

@objc func snapshot(_ keepTransparency: Bool = false) -> JSPromise? {
guard id > 0, let windowID = CGWindowID(exactly: id) else {
return JSEngine.shared.createPromise { holder in
Task.detached {
await holder.rejectWithMessage("hs.window.snapshot: window has no valid ID")
}
}
}
return captureWindowSnapshot(windowID: windowID, keepTransparency: keepTransparency)
}

// MARK: - Advanced

@objc func axElement() -> HSAXElement {
Expand Down
25 changes: 25 additions & 0 deletions Hammerspoon 2/Modules/hs.window/HSWindowModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ import AXSwift
/// ```
@objc func orderedWindows() -> [HSWindow]

/// Capture the current on-screen contents of the window with the given ID.
///
/// Requires **Screen Recording** permission.
///
/// - Parameters:
/// - id: The window's underlying ID (see the `id` property on `hs.window` objects).
/// - keepTransparency?: Whether to preserve the window's alpha channel. If `false` (the default), transparent regions are filled with an opaque black background.
/// - Returns: {Promise<HSImage>} Resolves with the captured image, or rejects if no window with that ID can be found, or the capture fails.
/// - Example:
/// ```js
/// hs.window.snapshotForID(12345).then(img => img.saveToFile("/tmp/window.png"))
/// ```
@objc func snapshotForID(_ id: Int, _ keepTransparency: Bool) -> JSPromise?

// MARK: - Swift-retained storage for JS-defined enhancements
// These are set by hs.window.js. They must be real, pre-declared properties (not
// dynamically-added JS properties) or JavaScriptCore silently drops them the first time
Expand Down Expand Up @@ -319,4 +333,15 @@ import AXSwift

return windows
}

@objc func snapshotForID(_ id: Int, _ keepTransparency: Bool = false) -> JSPromise? {
guard id > 0, let windowID = CGWindowID(exactly: id) else {
return JSEngine.shared.createPromise { holder in
Task.detached {
await holder.rejectWithMessage("hs.window.snapshotForID: invalid window ID \(id)")
}
}
}
return captureWindowSnapshot(windowID: windowID, keepTransparency: keepTransparency)
}
}
113 changes: 91 additions & 22 deletions docs/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -24951,6 +24951,44 @@
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindowModule.swift",
"lineNumber": 78
},
{
"name": "snapshotForID",
"signature": "func snapshotForID(_ id: Int, _ keepTransparency: Bool) -> JSPromise?",
"isStatic": false,
"rawDocumentation": "Capture the current on-screen contents of the window with the given ID.\n\nRequires **Screen Recording** permission.\n\n- Parameters:\n - id: The window's underlying ID (see the `id` property on `hs.window` objects).\n - keepTransparency?: Whether to preserve the window's alpha channel. If `false` (the default), transparent regions are filled with an opaque black background.\n- Returns: {Promise<HSImage>} Resolves with the captured image, or rejects if no window with that ID can be found, or the capture fails.\n- Example:\n```js\nhs.window.snapshotForID(12345).then(img => img.saveToFile(\"/tmp/window.png\"))\n```",
"description": "Capture the current on-screen contents of the window with the given ID.\nRequires **Screen Recording** permission.",
"params": [
{
"name": "id",
"type": "number",
"description": "The window's underlying ID (see the `id` property on `hs.window` objects).",
"optional": false,
"tsType": null
},
{
"name": "keepTransparency",
"type": "boolean",
"description": "Whether to preserve the window's alpha channel. If `false` (the default), transparent regions are filled with an opaque black background.",
"optional": true,
"tsType": null
}
],
"returns": {
"type": "JSPromise",
"description": "Resolves with the captured image, or rejects if no window with that ID can be found, or the capture fails.",
"promiseType": "HSImage"
},
"notes": [],
"examples": [
{
"lang": "js",
"code": "hs.window.snapshotForID(12345).then(img => img.saveToFile(\"/tmp/window.png\"))"
}
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindowModule.swift",
"lineNumber": 92
},
{
"name": "findByTitle",
"rawDocumentation": "Find windows by title\nParameter title: The window title to search for. All windows with titles that include this string, will be matched\nReturns: {HSWindow[]} An array of HSWindow objects with matching titles",
Expand Down Expand Up @@ -25083,7 +25121,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 139
"lineNumber": 174
},
{
"name": "minimize",
Expand All @@ -25105,7 +25143,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 148
"lineNumber": 183
},
{
"name": "unminimize",
Expand All @@ -25127,7 +25165,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 157
"lineNumber": 192
},
{
"name": "raise",
Expand All @@ -25149,7 +25187,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 166
"lineNumber": 201
},
{
"name": "toggleFullscreen",
Expand All @@ -25171,7 +25209,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 175
"lineNumber": 210
},
{
"name": "close",
Expand All @@ -25193,7 +25231,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 184
"lineNumber": 219
},
{
"name": "centerOnScreen",
Expand All @@ -25212,7 +25250,38 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 193
"lineNumber": 228
},
{
"name": "snapshot",
"signature": "func snapshot(_ keepTransparency: Bool) -> JSPromise?",
"isStatic": false,
"rawDocumentation": "Capture the current on-screen contents of this window as an image.\n\nRequires **Screen Recording** permission.\n\n- Parameter keepTransparency?: Whether to preserve the window's alpha channel. If `false` (the default), transparent regions are filled with an opaque black background.\n- Returns: {Promise<HSImage>} Resolves with the captured image, or rejects if the capture fails (e.g. permission denied, or the window could no longer be located).\n- Example:\n```js\nconst win = hs.window.focusedWindow()\nwin.snapshot().then(img => img.saveToFile(\"/tmp/window.png\"))\n```",
"description": "Capture the current on-screen contents of this window as an image.\nRequires **Screen Recording** permission.",
"params": [
{
"name": "keepTransparency",
"type": "boolean",
"description": "Whether to preserve the window's alpha channel. If `false` (the default), transparent regions are filled with an opaque black background.",
"optional": true,
"tsType": null
}
],
"returns": {
"type": "JSPromise",
"description": "Resolves with the captured image, or rejects if the capture fails (e.g. permission denied, or the window could no longer be located).",
"promiseType": "HSImage"
},
"notes": [],
"examples": [
{
"lang": "js",
"code": "const win = hs.window.focusedWindow()\nwin.snapshot().then(img => img.saveToFile(\"/tmp/window.png\"))"
}
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 243
},
{
"name": "axElement",
Expand All @@ -25234,7 +25303,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 204
"lineNumber": 254
}
],
"properties": [
Expand All @@ -25253,7 +25322,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 27
"lineNumber": 62
},
{
"name": "application",
Expand All @@ -25270,7 +25339,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 35
"lineNumber": 70
},
{
"name": "pid",
Expand All @@ -25287,7 +25356,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 43
"lineNumber": 78
},
{
"name": "id",
Expand All @@ -25304,7 +25373,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 52
"lineNumber": 87
},
{
"name": "isMinimized",
Expand All @@ -25321,7 +25390,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 62
"lineNumber": 97
},
{
"name": "isVisible",
Expand All @@ -25338,7 +25407,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 70
"lineNumber": 105
},
{
"name": "isFocused",
Expand All @@ -25355,7 +25424,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 78
"lineNumber": 113
},
{
"name": "isFullscreen",
Expand All @@ -25372,7 +25441,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 86
"lineNumber": 121
},
{
"name": "isStandard",
Expand All @@ -25389,7 +25458,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 94
"lineNumber": 129
},
{
"name": "position",
Expand All @@ -25406,7 +25475,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 104
"lineNumber": 139
},
{
"name": "size",
Expand All @@ -25423,7 +25492,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 112
"lineNumber": 147
},
{
"name": "frame",
Expand All @@ -25440,7 +25509,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 120
"lineNumber": 155
},
{
"name": "screen",
Expand All @@ -25457,7 +25526,7 @@
],
"source": "swift",
"filePath": "Hammerspoon 2/Modules/hs.window/HSWindow.swift",
"lineNumber": 128
"lineNumber": 163
}
]
}
Expand Down Expand Up @@ -27544,5 +27613,5 @@
"types": []
}
],
"generatedAt": "2026-09-09T14:10:40.769Z"
"generatedAt": "2026-09-09T15:56:16.638Z"
}
Loading
Loading