Fory Version Bump
apache/fory
Bump Apache Fory release or post-release development versions across Java, Kotlin, Scala, Python, Rust, Go, C++, C, Dart, JavaScript, Swift, integration tests, examples, and source docs.
Ensure all Hammerspoon v2 modules follow established patterns
$ npx skills add cmsj/Hammerspoon2 --skill hs2module -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2module --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/HSModule .claude/skills/hs2module && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "hs2module" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModule into .claude/skills/hs2module/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2module", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModuleType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add cmsj/Hammerspoon2 --skill hs2module -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2module --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.claude/skills/HSModule .agents/skills/hs2module && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "hs2module" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModule into .agents/skills/hs2module/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2module", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cmsj/Hammerspoon2 --skill hs2module -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2module --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.claude/skills/HSModule .cursor/skills/hs2module && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "hs2module" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModule into .cursor/skills/hs2module/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2module", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/cmsj/Hammerspoon2.git --path .claude/skills/HSModule--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add cmsj/Hammerspoon2 --skill hs2module -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2module --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.claude/skills/HSModule .gemini/skills/hs2module && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "hs2module" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModule into .gemini/skills/hs2module/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2module", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install cmsj/Hammerspoon2 hs2moduleInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add cmsj/Hammerspoon2 --skill hs2module -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .github/skills && cp -r skills-src/.claude/skills/HSModule .github/skills/hs2module && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "hs2module" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModule into .github/skills/hs2module/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2module", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cmsj/Hammerspoon2 --skill hs2module -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2module --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.claude/skills/HSModule .opencode/skills/hs2module && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "hs2module" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSModule into .opencode/skills/hs2module/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2module", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
hs2moduleEnsure all Hammerspoon v2 modules follow established patterns
Hs2module is an agent skill from cmsj/Hammerspoon2. Ensure all Hammerspoon v2 modules follow established patterns
Its SKILL.md is about 8.6k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Mobile. It works with JavaScript and Objective-C. The licence is MIT.
3 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit f426cf3. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
npmFrom the folder's file list and the shell code blocks in SKILL.md.
Links to these hosts (documentation or services it may open):
github.comcocoamine.netFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Hs2module loads about 8.6k tokens when it runs. Until then it costs about 18 tokens; SKILL.md has 3,500 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from cmsj/Hammerspoon2 at commit f426cf3, republished under its MIT licence (© cmsj). 3,500 words, ~8,637 tokens.
.claude/skills/hs2module/SKILL.md (or your agent's skills folder).Hammerspoon 2 consists of two main components:
In the directory "Hammerspoon 2/Modules", each within a further directory that shares the name they will be exposed to JS with, e.g. "hs.location"
A new module needs to be registered with the core JS engine so it can be loaded, these are both in Engine/ModuleRoot.swift:
This will automatically load hs.foo.js if it exists in the Hammerspoon 2 app bundle, as well as exposing the HSFooModule class to JavaScript
Additionally, the module should be exposed to "Hammerspoon 2Tests/Helpers/JStestHarness.swift" in the loadModules switch: case "foo": loadModule(HSFooModule.self, as: name)
For the case of a module that we intend to be accessible in JS as "hs.foo", the following structural rules must be observed:
@MainActor (all JS-facing code runs on the main thread)var name: String set to "hs.foo"let engineID: UUID — stored property identifying which engine instance owns this modulerequired init(engineID: UUID) {
self.engineID = engineID
// ... any pre-super initialisation ...
super.init()
AKGarbage("Init of \(name): \(engineID)")
}shutdown() method called by the core engine when tearing down the JS environment@_documentation(visibility: private)isolated deinit that calls AKGarbage("Deinit of \(name): \(engineID)").AKGarbage() to announce their deinitialisation.obj.delete() is a syntax error). Use a descriptive alternative such as deletePath(), destroy(), or remove() instead.When a module creates child objects that are returned to JavaScript (e.g. HSTimer, HSHotkey, HSBonjourSearch), it must track them so shutdown() can clean them all up.
The canonical pattern is HSWeakObjectSet<T> (Engine/HSWeakObjectSet.swift):
// Declaration (in the module class body)
private var children = HSWeakObjectSet<HSChild>()
// Adding a new child
children.add(child)
// Removing an explicit child (e.g. in removeSearch/removeWatcher)
children.remove(child)
// Shutdown — allObjects returns only live entries (dead ones are compacted on access)
func shutdown() {
for child in children.allObjects {
child.destroy()
}
children.removeAllObjects()
}Every child object returned to JS must have a destroy() method that:
Both isolated deinit and the hosting module's shutdown() must call destroy(). This ensures cleanup happens whether the object is collected by the JS GC or torn down explicitly by the module.
@objc class HSChild: NSObject, HSChildAPI {
private var callback: JSCallback?
func destroy() {
stop() // deactivate
callback?.detach(from: self) // detach JS callback
callback = nil
}
isolated deinit {
destroy()
AKGarbage("deinit of HSChild")
}
}Why HSWeakObjectSet and not NSHashTable.weakObjects()?
NSHashTable.weakObjects() has documented autoreleasepool interactions where entries can persist or zero out unpredictably (see https://github.com/GitHawkApp/FlatCache/issues/3 and http://cocoamine.net/blog/2013/12/13/nsmaptable-and-zeroing-weak-references/). HSWeakObjectSet uses plain Swift weak var references which zero correctly per ARC semantics.HSWeakObjectSet stores entries in a [ObjectIdentifier: WeakBox] dictionary, giving O(1) add/remove. Dead entries are compacted lazily on allObjects access.removeXxx() call.HSHotkey alive while enabled; the run loop Timer keeps HSTimer alive while running; CLLocationManager holds HSLocationWatcher alive via its delegate). Module tracking is only needed for shutdown().When a JS-exported Swift object stores a JavaScript callback function, always use JSCallback (Engine/JSCallback.swift), never a raw JSValue.
Why: Storing a raw JSValue in a JS-exported Swift object creates a retain cycle that prevents the JS GC from ever collecting the object:
Swift object → JSValue → JSContext → JS wrapper → Swift object
JSCallback breaks the cycle using JSManagedValue. The VM tracks the callback as a conditional reference: alive only while the owner is reachable from JS. When JS GC collects the wrapper, the managed value clears and the Swift object can be freed.
Critical design detail: The JSVirtualMachine is captured at init time (when JSContext.current() is valid — JS is calling into Swift). This allows detach(from:) to work correctly even when called outside JS execution (e.g., from shutdown()). A naive implementation using JSContext.current() at detach time silently fails because that call returns nil when called from Swift outside a JS callback.
Usage — callback set once at init:
private var callback: JSCallback?
init(..., callback: JSValue, ...) {
// ... stored properties ...
super.init()
// Phase 2 — JSContext.current() is non-nil here (called from JS bridge)
self.callback = JSCallback(value: callback, owner: self)
}
func destroy() {
callback?.detach(from: self) // owner passed explicitly — weak var would be zeroed in deinit
callback = nil
}
func fireCallback() {
callback?.value?.call(withArguments: [...])
}Usage — callback exposed as a settable JS property (e.g. HSHotkey.callbackPressed):
When the protocol declares @objc var callbackPressed: JSValue? { get set }, the implementation uses a computed property with JSCallback? backing — the JS-visible type is unchanged:
private var _callbackPressed: JSCallback?
@objc var callbackPressed: JSValue? {
get { _callbackPressed?.value }
set {
_callbackPressed?.detach(from: self)
_callbackPressed = newValue.flatMap { JSCallback(value: $0, owner: self) }
}
}Do not use JSCallback for:
HSCamera, HSAudioDevice use selfRetain = self while a watcher is registered). The retain cycle is intentional there; breaking it would cause premature deallocation.HSAXWatcherObject).Exception — strong refs for visible UI objects:
hs.ui intentionally uses [UUID: HSUIWindow] (strong) for windows, alerts, and dialogs. These must stay alive on screen even if JS drops the reference. This is the only legitimate reason to use strong tracking. UI objects call back to the module to register/unregister themselves when shown/closed.
Every JSValue stored anywhere in Swift holds the entire old JSContext alive until that JSValue is released. If hs.reload() is called and any Swift object is still holding a JSValue from the old context, that context — and its entire JS heap, including all JS proxies for Swift objects — will remain in memory indefinitely.
This section documents the patterns that cause these leaks and how to prevent them.
A closure that closes over a JSValue parameter is functionally identical to storing that JSValue directly. Both hold a strong reference to the old JSContext.
// WRONG — the closure captures `callback` by value; JSValue is retained
interactive.clickCallback = { callback.call(withArguments: []) }
// WRONG — equivalent problem; isHovered is Bool but callback is captured
interactive.hoverCallback = { isHovered in callback.call(withArguments: [isHovered]) }Any closure stored in a property (even a non-JSValue typed property like (() -> Void)?) that was created by capturing a JSValue must be cleared during shutdown with the same urgency as a raw JSValue.
If JSValue-capturing closures are stored in sub-objects (e.g. an element tree), the parent object must explicitly nil those sub-objects during close()/shutdown() — it is not sufficient to just release the parent via ARC, because ARC only runs when the retain count reaches zero, which may not happen until the JS proxy for the parent is GC'd (which requires the context to be freed first — a chicken-and-egg deadlock).
The correct pattern is to break the chain eagerly in close():
@objc func close() {
guard isOpen else { return }
// Eagerly release the element tree so closures that captured JSValue callbacks
// are freed NOW, before this object's own retain count reaches zero.
rootElement = nil
currentElement = nil
containerStack.removeAll()
// ... close the window etc. ...
}_watcherEmitter must be nilled in shutdown()Every module that uses the Pattern A watcher stores a JS EventEmitter instance in _watcherEmitter: JSValue?. This JSValue holds the old JSContext alive. It MUST be nilled in shutdown() — calling _removeWatcher() alone is not sufficient, because _removeWatcher() only removes the underlying OS listener, not the emitter value itself.
func shutdown() {
_removeWatcher()
_watcherEmitter = nil // REQUIRED — releases the JSValue holding the old context
}The same applies to any other JSValue? stored directly on a module (e.g. doUntil, doWhile, waitUntil, waitWhile on HSTimerModule): they must all be nilled in shutdown().
close() is the single cleanup point — not deinitFor objects that have a close() or destroy() lifecycle method, all JSValue cleanup must happen in close()/destroy(), not only in deinit. This is because:
shutdown() calls close()/destroy() explicitly — this is the primary cleanup path.deinit runs only after the object's ARC count drops to zero.deinit never runs — but close() already ran, so that's fine. If close() didn't release the JSValues, the proxy prevents the context from being freed, which prevents deinit from running — a deadlock.The dependency is:
close()releases JSValues → JSContext freed → JS proxy collected → ARC drops →deinitruns
Reversing this by relying on deinit to release JSValues breaks the chain.
For objects that show a modal UI and invoke a callback exactly once (e.g. HSUITextPrompt, HSUIFilePicker), nil the callback immediately after calling it. There is no close() to clear it, and the object may remain alive for a long time if not GC'd:
@objc func show() {
// ... run the modal ...
if let callback = buttonCallback {
buttonCallback = nil // release BEFORE calling, so it's freed even if callback throws
callback.call(withArguments: [buttonIndex, inputText])
}
}Nilling before calling (rather than after) is slightly safer: if the callback throws a JS exception, the JSValue is still released.
When writing or reviewing a module's shutdown(), verify:
_removeWatcher() called if the module uses Pattern A watchers_watcherEmitter = nil set after _removeWatcher()JSValue? property stored on the module is nilledJSValue?-capturing closure stored on the module or its sub-objects is cleareddestroy() called on all tracked child objects (which detach their own JSCallbacks)close() on long-lived UI objects clears element trees and callback properties eagerlyThe HSFooModuleAPI protocol should observe the following rules:
JSValue must NOT appear in @objc protocol ... JSExport declarations as a parameter type or return type for any value that has a concrete Swift type. Use the appropriate Swift type instead:
| Instead of | Use |
|---|---|
JSValue (string) | String or String? |
JSValue (number) | Int, Double, etc. |
JSValue (boolean) | Bool |
JSValue (array) | [String], [Any], etc. |
JSValue (object) | [String: Any] |
JSValue is only acceptable for JavaScript function callbacks (there is no concrete Swift type for a JS function). Examples where JSValue is correct:
@objc func addWatcher(_ listener: JSValue) // listener is a JS function
@objc func setCallback(_ fn: JSValue) -> HSXxxWatcher // fn is a JS function
@objc var callbackPressed: JSValue? { get set } // property holding a JS functionIf a parameter needs to accept two different concrete types (e.g. a string OR an array), do not reach for JSValue — instead split into two separate methods with descriptive names:
// BAD — hides the type contract, breaks TypeScript, forces runtime JSValue inspection
@objc func findMenuItem(_ item: JSValue) -> [String: Any]?
// GOOD — clear types, two separate entry points
@objc func findMenuItemByName(_ name: String) -> [String: Any]?
@objc func findMenuItemByPath(_ path: [String]) -> [String: Any]?When a @objc method has a Bool parameter, JavaScript callers that omit the argument receive false at the Swift layer (ObjC bridges undefined/missing to NO/false). This has an important consequence for API design:
Design Bool-parameter APIs so that false represents the safe, common, or "do nothing extra" behaviour. The user gets false for free when they call the method with no argument.
// GOOD — false means "don't raise all windows", which is the common case
@objc func activate(_ allWindows: Bool) // app.activate() works naturally
// GOOD — false means "not hidden", so create() makes a visible item by default
@objc func create(_ hidden: Bool) -> HSMenuBarItem // hs.menubar.create() → visible
// BAD — false means "not visible", so create() would create a hidden item
// and callers would need create(true) for the common case
@objc func create(_ visible: Bool) -> HSMenuBarItemIf the natural default behaviour maps to true, invert the parameter name so false is the default (e.g. rename visible → hidden, enabled → disabled, synchronous → async).
Several modules return Promises for async operations. The return type is JSPromise? (a
typealias for JSValue), and the docstring - Returns: line should include {Promise<T>} to
signal this to the docs generator.
Promises MUST be created in the JSContext that made the call, not in JSEngine.shared's
context. JSEngine.shared has its own JSContext; if you create a Promise there and return
it to JS running in a different context (e.g. the test harness), JavaScriptCore delivers
it as an opaque ObjC object with no then method — the JS caller cannot use it as a
Promise at all.
The correct pattern is:
// In the protocol:
/// - Returns: {Promise<boolean>} A Promise that resolves to true if successful
@objc func doSomethingAsync() -> JSPromise?
// In the implementation:
@objc func doSomethingAsync() -> JSPromise? {
guard let context = JSContext.current() else { return nil }
return wrapAsyncInJSPromise(in: context) { holder in
Task { @MainActor in
// ... do async work ...
holder.resolveWith(result) // or:
holder.rejectWithMessage("reason")
}
}
} JSContext.current() returns the JSContext that invoked the current @objc method via
JSExport. It is always non-nil when called from a JS→Swift bridge method, so the guard is
just a safety net.
Never use JSEngine.shared.createPromise in an @objc JSExport method — that creates
the Promise in the wrong context.
For immediately-known results, use the JSContext extensions instead of JSEngine.shared:
guard let context = JSContext.current() else { return nil }
return context.createResolvedPromise(with: value)
return context.createRejectedPromise(with: "error message")Hammerspoon 2 has two established watcher patterns. Choose the right one based on whether the underlying OS API is singleton/global or per-object.
Use this when there is a single global OS subscription (NSWorkspace notification, CoreAudio listener, IOKit notification, a polling timer, etc.) that serves all JS callers. hs.screen is the smallest complete example to copy.
The architecture is always two layers: a private Swift subscription (_addWatcher /
_removeWatcher) that feeds a shared JS LazyWatcherEmitter (Engine/engine.js), which owns
the listeners and starts/stops the Swift subscription lazily. The public on/off/once are
defined in the companion JS file, not in Swift.
The contract (issue #254): if on()/once() returns, the listener is registered AND the
native watcher is running; otherwise it throws and records nothing, so a retry works. Never
just log and return.
@objc protocol HSXxxModuleAPI: JSExport block)// NOTE: Private API consumed only by hs.xxx.js
/// SKIP_DOCS
@objc(_addWatcher:) func _addWatcher(_ callback: JSFunction) -> Bool
/// SKIP_DOCS
@objc func _removeWatcher()
/// SKIP_DOCS
@objc var _watcherEmitter: JSFunction? { get set }
/// The event names `on()`/`once()` accept - see HSXxxEvent
/// SKIP_DOCS
@objc var _eventNames: [String] { get }
// Set by hs.xxx.js. Must be pre-declared properties, or JSC drops them on GC (issue #185).
/// SKIP_DOCS
@objc var on: JSFunction? { get set }
/// SKIP_DOCS
@objc var off: JSFunction? { get set }
/// SKIP_DOCS
@objc var once: JSFunction? { get set }/// Events emitted by hs.xxx's watcher
nonisolated enum HSXxxEvent: String, HSEventName {
case change
}
@objc var _watcherEmitter: JSFunction? = nil
@objc var _eventNames: [String] { HSXxxEvent.allNames }
@objc var on: JSFunction? = nil
@objc var off: JSFunction? = nil
@objc var once: JSFunction? = nil
private var watcherCallback: JSFunction?
@objc(_addWatcher:) func _addWatcher(_ callback: JSFunction) -> Bool {
guard watcherCallback == nil else {
AKWarning("hs.xxx._addWatcher: already watching — refusing second subscription")
return false
}
// ... register with the OS API here. If that can fail, check it and return false
// BEFORE setting any state, so a failed attempt leaves nothing to clean up ...
watcherCallback = callback
AKDebug("hs.xxx._addWatcher: started")
return true
}
@objc func _removeWatcher() {
guard watcherCallback != nil else { return }
// ... unregister from the OS API ...
watcherCallback = nil
AKDebug("hs.xxx._removeWatcher: stopped")
}
private func fireWatcherEvent() {
_ = watcherCallback?.call(withArguments: [HSXxxEvent.change.rawValue])
}
func shutdown() {
_removeWatcher()
_watcherEmitter = nil
on = nil
off = nil
once = nil
}Key rules:
_addWatcher MUST return Bool: false for any failure, including refusing a second
subscription; true on success. Every false path MUST log its specific cause (AKError, or
AKWarning for the refusal) - the JS emitter's error is deliberately generic and not logged,
so the native log is what shows the reason, including when on() is called from a promise
callback and the throw becomes an unhandled rejection.HSXxxEvent.foo.rawValue, never a string literal (see Event names below)._watcherEmitter and the on/off/once slots."use strict";
// Lazily starts the native watcher on the first listener and stops it once the last
// listener is removed. See Engine/engine.js for LazyWatcherEmitter itself.
hs.xxx._watcherEmitter = new LazyWatcherEmitter("hs.xxx", function() {
return hs.xxx._addWatcher((event) => {
hs.xxx._watcherEmitter.emit(event);
});
}, function() {
hs.xxx._removeWatcher();
}, hs.xxx._eventNames);
/// Register a listener for Xxx events.
/// Parameters:
/// - event: {"change"} The event to listen for
/// - listener: {() => void} Called when the event occurs
/// Throws: true
/// Example:
/// ```js
/// try {
/// hs.xxx.on('change', () => console.log("changed"))
/// } catch (err) {
/// console.error(err.message)
/// }
/// ```
hs.xxx.on = function(event, listener) {
hs.xxx._watcherEmitter.on(event, listener);
};
// hs.xxx.off and hs.xxx.once follow the same shape; once() also gets `Throws: true` and a
// try/catch example, off() gets neither.Key rules:
return the native _addWatcher(...) result. LazyWatcherEmitter
throws a standard "hs.xxx.on(): failed to start watcher for '<event>'" error when it returns
false; don't hand-write if (!started) throw ....Throws: true (JS) / - Throws: true (Swift), and wrap
their examples in try { ... } catch (err) { console.error(err.message) }. The docs
pipeline renders that as a Throws section and @throws {Error}. Per-instance watchers (HSCamera, HSAudioDevice): on()/once() are native Swift methods, since a
per-instance object has no enhancement script to hang them on. They get the emitter from a
module-level JS factory (e.g. hs.camera._makeCameraEmitter). They MUST:
ctx.exception and return if the factory is missing or fails to produce an emitter
(see HSCamera.ensureWatcherEmitter), rather than silently returning;ctx.callCapturingException { emitter.invokeMethod(...) }.
Plain invokeMethod never delivers the emitter's throw to the JS caller's try/catch.Event names (issue #253):
nonisolated enum HSXxxEvent: String, HSEventName { case ... }
(see Engine/HSEventName.swift). Emit ONLY via HSXxxEvent.foo.rawValue, never a string literal, so
the compiler guarantees every emitted name is in the list./// SKIP_DOCS @objc var _eventNames: [String] { get }, implemented as
HSXxxEvent.allNames, and pass hs.xxx._eventNames as the 4th argument to LazyWatcherEmitter /
KeyedLazyWatcherEmitter. on()/once() then throw for any other name instead of registering a
listener that can never fire.npm run docs:test
(scripts/check-event-names.js) fails otherwise, and also fails if an emitter is given no list.
Only emitters whose names are genuinely arbitrary (hs.userdefaults) are exempted in that script.Pattern B — Object-level watcher (hs.ax, hs.location)
Use this when each watcher is a discrete, independently-configured object with its own OS subscription and callback — for example, per-app AX observers or per-session location updates.
The watcher class
// Separate file: HSXxxWatcher.swift
@objc protocol HSXxxWatcherAPI: HSTypeAPI, JSExport { @objc var identifier: String { get } // UUID string, set in init @objc @discardableResult func start() -> HSXxxWatcher @objc @discardableResult func stop() -> HSXxxWatcher @objc func setCallback(_ fn: JSValue) -> HSXxxWatcher // ... any additional config properties (e.g. distanceFilter) ... }
@_documentation(visibility: private) @MainActor @objc class HSXxxWatcher: NSObject, HSXxxWatcherAPI /*, OS delegate if needed */ { @objc var typeName = "HSXxxWatcher" @objc let identifier = UUID().uuidString private var callback: JSCallback?
override init() {
super.init()
// set self as OS delegate if needed
}
isolated deinit {
destroy()
AKGarbage("deinit of HSXxxWatcher(\(identifier))")
}
func destroy() {
_ = stop()
callback?.detach(from: self)
callback = nil
}
@objc @discardableResult func start() -> HSXxxWatcher {
// begin OS updates
return self
}
@objc @discardableResult func stop() -> HSXxxWatcher {
// end OS updates
return self
}
@objc func setCallback(_ fn: JSValue) -> HSXxxWatcher {
callback?.detach(from: self)
callback = JSCallback(value: fn, owner: self)
return self
}
// OS delegate callbacks use MainActor.assumeIsolated { } and
// `_ = callback?.value?.call(withArguments: [...])` to invoke.
}
Module additions
In the module's Swift protocol add:
/// Creates a new Xxx watcher. Call .start() and .setCallback() to activate it.
/// The watcher is stopped automatically when the module shuts down.
/// - Returns: an HSXxxWatcher
/// - Example:
/// js /// const w = hs.xxx.addWatcher() /// w.setCallback((event, data) => console.log(event, data)).start() ///
@objc func addWatcher() -> HSXxxWatcher
In the module's Swift implementation:
private var watchers = HSWeakObjectSet<HSXxxWatcher>()
func addWatcher() -> HSXxxWatcher { let w = HSXxxWatcher() watchers.add(w) return w }
func removeWatcher(_ watcher: HSXxxWatcher) { watcher.destroy() watchers.remove(watcher) }
func shutdown() { for watcher in watchers.allObjects { watcher.destroy() } watchers.removeAllObjects() // stop any module-level OS state too }
Key rules:
private var watchers = HSWeakObjectSet<HSXxxWatcher>().destroy() method (see Child object tracking section).destroy() on all tracked watchers, not just stop().Key rules:
private var watchers = HSWeakObjectSet<HSXxxWatcher>().destroy() method (see Child object tracking section).destroy() on all tracked watchers, not just stop().Pattern B with multiplexing (hs.ax)
When Pattern B watcher objects are keyed by a composite identity (e.g. pid:notification), keep an additional dictionary of the underlying OS observers:
private var observers: [KeyType: OSObserver] = [:] private var watchers: [String: HSXxxWatcher] = [:] // keyed by "component1:component2"
And in _removeWatcher, only tear down the OS observer when the last watcher for that key prefix is removed (see HSAXModule._removeWatcher for the reference implementation).
Logging conventions for watchers
The section covers both patterns (module-level EventEmitter and object-level watcher), the JS emitter template, the composite-key variant from hs.ax, and all the subtle rules around lazy start, [weak self],
assumeIsolated, discarding JSValue?, and the _watcherEmitter GC anchor.
<boolean>} A Promise resolving to...Foo?) must have ? appended to the parameter name in the docstring to signal optionality to the docs pipeline: /// - Parameter headers?: Optional dictionary of request headers.Hammerspoon 2 provides several convenience functions that handle logging per the user's configuration:
These all log into the app's Console window
If we ever emit a console.log() call, even in example code, please note that Hammerspoon 2's implementation of console.log does not support the form console.log("foo: ", bar); - either concatenate strings with + or use interpolated strings.
Avoid using print() if possible.
© cmsj, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in .claude/skills/HSModule of cmsj/Hammerspoon2.
Open the folder on GitHubat commit f426cf3
Hs2module next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Hs2module this skillcmsj/Hammerspoon2 | 115 | — | ~8.6k | Automated safety check: Pass | MIT | |
| Fory Version Bumpapache/fory | 4.6k | — | ~1.1k | Automated safety check: Pass | Apache-2.0 | |
| App Store Reviewsafaiyeh/app-store-review-skill | 362 | 1 repos | ~3.4k | Automated safety check: Pass | MIT | |
| Fory Performance Optimizationapache/fory | 4.6k | — | ~2.2k | Automated safety check: Pass | Apache-2.0 | |
| Legado Book Source GeneratorNarylr350/book-source-creator-skill | 154 | — | ~1.9k | Automated safety check: Pass | None | |
| Agent Cdpgronxb/codex-relay | 679 | — | ~956 | Automated safety check: Pass | Apache-2.0 |
apache/fory
Bump Apache Fory release or post-release development versions across Java, Kotlin, Scala, Python, Rust, Go, C++, C, Dart, JavaScript, Swift, integration tests, examples, and source docs.
safaiyeh/app-store-review-skill
Evaluates code against Apple's App Store Review Guidelines. An agent skill from safaiyeh/app-store-review-skill.
apache/fory
Run profile-driven bottleneck optimization across Apache Fory implementations (Java, C++, Python/Cython, Go, Rust, Swift, C, JavaScript/TypeScript, Dart, Kotlin, Scala).
Narylr350/book-source-creator-skill
A skill your agent uses when 用户要求为任意网站生成书源、生成阅读书源、分析小说站点、生成 Legado/阅读规则。强制触发词:书源、生成书源、帮我生成、book source、legado、阅读书源、小说站点分析。如果用户给出了一个 URL 并要求生成或分析,必须加载此 skill。
gronxb/codex-relay
Chrome DevTools Protocol CLI workflow for runtime, console, network, trace, memory, and JavaScript CPU profiling analysis.
crossbind/crossbind
A skill your agent uses when a user wants to call C++ or Rust from JavaScript or TypeScript; add a native library such as GDAL, SQLite, OpenSSL, GEOS or PROJ to a browser, Node.js, Cloudflare Worker…
Works with
Categories
Ensure all Hammerspoon v2 modules follow established patterns. Hs2module is an agent skill from cmsj/Hammerspoon2.
Hs2module fits situations like: mobile work in your project.
Run `npx skills add cmsj/Hammerspoon2 --skill hs2module -a claude-code`. Or copy the skill folder (.claude/skills/HSModule in cmsj/Hammerspoon2) into .claude/skills/hs2module in your project. Claude Code loads it when a task matches its description.
Run `npx skills add cmsj/Hammerspoon2 --skill hs2module -a codex`. Or copy the skill folder (.claude/skills/HSModule in cmsj/Hammerspoon2) into .agents/skills/hs2module in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add cmsj/Hammerspoon2 --skill hs2module -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/hs2module, .gemini/skills/hs2module, .github/skills/hs2module and .opencode/skills/hs2module in your project.
Going by SKILL.md and its folder, Hs2module needs the command-line tools its instructions call (npm).
SKILL.md names 2 domains. As links in the text: github.com and cocoamine.net. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Hs2module is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 8.6k tokens (SKILL.md is roughly 35k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Hs2module: Fory Version Bump (apache/fory, 4.6k stars), App Store Review (safaiyeh/app-store-review-skill, 362 stars), Fory Performance Optimization (apache/fory, 4.6k stars) and Legado Book Source Generator (Narylr350/book-source-creator-skill, 154 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
cmsj (a GitHub user) maintains it in cmsj/Hammerspoon2, which has 115 GitHub stars. The repository holds 3 skills in this directory. The repository was last updated on October 7, 2026.
Source: cmsj/Hammerspoon2 on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.