Skip to content
Open
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
20 changes: 18 additions & 2 deletions Hammerspoon 2.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
4F488B302E97E79800A2B5F4 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 4F488B2F2E97E79800A2B5F4 /* Sparkle */; };
4F59E3502EFC986100954462 /* hammerspoon.d.ts in Resources */ = {isa = PBXBuildFile; fileRef = 4F59E34F2EFC985500954462 /* hammerspoon.d.ts */; };
4F59E3512EFC986100954462 /* api.json in Resources */ = {isa = PBXBuildFile; fileRef = 4F59E34E2EFC984900954462 /* api.json */; };
EC3BA53B2F013CE0001291A0 /* InternalESModuleForSwiftJavaScriptCore in Frameworks */ = {isa = PBXBuildFile; productRef = EC3BA53A2F013CE0001291A0 /* InternalESModuleForSwiftJavaScriptCore */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
4F5641912E8333840099EB4C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 4F56417B2E8333830099EB4C /* Project object */;
proxyType = 1;
proxyType = 0;
remoteGlobalIDString = 4F5641822E8333830099EB4C;
remoteInfo = "Hammerspoon 2";
};
Expand Down Expand Up @@ -59,6 +60,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EC3BA53B2F013CE0001291A0 /* InternalESModuleForSwiftJavaScriptCore in Frameworks */,
4F488B302E97E79800A2B5F4 /* Sparkle in Frameworks */,
4F463EA02EA7A669000A2135 /* AXSwift in Frameworks */,
);
Expand Down Expand Up @@ -151,6 +153,7 @@
packageProductDependencies = (
4F488B2F2E97E79800A2B5F4 /* Sparkle */,
4F463E9F2EA7A669000A2135 /* AXSwift */,
EC3BA53A2F013CE0001291A0 /* InternalESModuleForSwiftJavaScriptCore */,
);
productName = "Hammerspoon 2";
productReference = 4F5641832E8333830099EB4C /* Hammerspoon 2.app */;
Expand Down Expand Up @@ -210,6 +213,7 @@
packageReferences = (
4F488B2E2E97E79800A2B5F4 /* XCRemoteSwiftPackageReference "Sparkle" */,
4F463E9E2EA7A669000A2135 /* XCRemoteSwiftPackageReference "AXSwift" */,
EC3BA5392F013CE0001291A0 /* XCRemoteSwiftPackageReference "InternalESModuleForSwiftJavaScriptCore" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 4F5641842E8333830099EB4C /* Products */;
Expand Down Expand Up @@ -261,7 +265,6 @@
/* Begin PBXTargetDependency section */
4F5641922E8333840099EB4C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 4F5641822E8333830099EB4C /* Hammerspoon 2 */;
targetProxy = 4F5641912E8333840099EB4C /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
Expand Down Expand Up @@ -576,6 +579,14 @@
minimumVersion = 2.8.0;
};
};
EC3BA5392F013CE0001291A0 /* XCRemoteSwiftPackageReference "InternalESModuleForSwiftJavaScriptCore" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ghostflyby/InternalESModuleForSwiftJavaScriptCore";
requirement = {
kind = exactVersion;
version = 1.0.1;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -589,6 +600,11 @@
package = 4F488B2E2E97E79800A2B5F4 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
EC3BA53A2F013CE0001291A0 /* InternalESModuleForSwiftJavaScriptCore */ = {
isa = XCSwiftPackageProductDependency;
package = EC3BA5392F013CE0001291A0 /* XCRemoteSwiftPackageReference "InternalESModuleForSwiftJavaScriptCore" */;
productName = InternalESModuleForSwiftJavaScriptCore;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 4F56417B2E8333830099EB4C /* Project object */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 74 additions & 35 deletions Hammerspoon 2/Engine/JSEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import InternalESModuleForSwiftJavaScriptCore
import JavaScriptCore

@_documentation(visibility: private)
Expand All @@ -15,6 +16,7 @@ class JSEngine {
private(set) var id = UUID()
private var vm: JSVirtualMachine?
private var context: JSContext?
private var moduleLoader: MultiRootFSModuleLoader?

// MARK: - Engine JavaScript component
private func injectEngineJS() {
Expand All @@ -28,39 +30,11 @@ class JSEngine {
}
}

private func injectRequire() {
guard let context else {
AKError("require(): Cannot set require() before context is available. This is a bug.")
return
}

let require: @convention(block) (String) -> (JSValue?) = { path in
let expandedPath = NSString(string: path).expandingTildeInPath

// Return void or throw an error here.
guard FileManager.default.fileExists(atPath: expandedPath) else {
AKError("require(): \(expandedPath) could not be found. Current working directory is \(FileManager.default.currentDirectoryPath)")
return nil
}

let fileURL = URL(fileURLWithPath: expandedPath)

guard let fileContent = try? String(contentsOfFile: expandedPath, encoding: .utf8) else {
AKError("require(): Unable to read \(expandedPath)")
return nil
}

return context.evaluateScript(fileContent, withSourceURL: fileURL)
}

context.setObject(require, forKeyedSubscript: "require" as NSString)
}

// MARK: - JSContext Managing
private func createContext() throws(HammerspoonError) {
AKTrace("createContext()")
vm = JSVirtualMachine()
guard vm != nil else {
guard let vm else {
throw HammerspoonError(.vmCreation, msg: "Unknown error (vm)")
}

Expand All @@ -72,14 +46,21 @@ class JSEngine {
id = UUID()
context.name = "Hammerspoon \(id)"

var baseURLs = [
URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
]
if let bundleURL = Bundle.main.resourceURL {
baseURLs.append(bundleURL)
}
let moduleLoader = MultiRootFSModuleLoader(virtualMachine: vm, baseURLs: baseURLs)
self.moduleLoader = moduleLoader
context.moduleLoaderDelegate = moduleLoader

// This is our startup sequence.

// First ensure the console namespace is populated
self["console"] = ConsoleModule()

// Now ensure that require() exists
injectRequire()

// Inject custom types we want to bridge between JS and Swift
context.injectTypeBridges()

Expand All @@ -93,13 +74,16 @@ class JSEngine {
private func deleteContext() {
AKTrace("deleteContext()")

if let hs = self["hs"] as? JSValue, let moduleRoot = hs.toObjectOf(ModuleRoot.self) as? ModuleRoot {
if let hs = self["hs"] as? JSValue,
let moduleRoot = hs.toObjectOf(ModuleRoot.self) as? ModuleRoot
{
moduleRoot.shutdown()
self["hs"] = nil
}

context = nil
vm = nil
moduleLoader = nil
}
}

Expand All @@ -125,8 +109,39 @@ extension JSEngine: JSEngineProtocol {
throw HammerspoonError(.jsEvalURLKind, msg: "Refusing to eval remote URL")
}

let script = try String(contentsOf: url, encoding: .utf8)
return context?.evaluateScript(script, withSourceURL: url)
guard let context else {
throw HammerspoonError(.unknown, msg: "JavaScript context is not available")
}
do {
guard let module = try moduleLoader?.module(for: url) else {
throw HammerspoonError(.jsModuleEvaluation, msg: "Unable to resolve module URL")
}
return try context.evaluate(esModule: module)
} catch {
throw HammerspoonError(.jsModuleEvaluation, msg: "\(error)")
}

}

@discardableResult func evalFromURL(_ url: URL) async throws -> Any? {
guard url.isFileURL else {
throw HammerspoonError(.jsEvalURLKind, msg: "Refusing to eval remote URL")
}

guard let context else {
throw HammerspoonError(.unknown, msg: "JavaScript context is not available")
}
do {
guard let module = try moduleLoader?.module(for: url) else {
throw HammerspoonError(.jsModuleEvaluation, msg: "Unable to resolve module URL")
}
let promise = try context.evaluate(esModule: module)
try await awaitPromise(promise, in: context)
return promise
} catch {
throw HammerspoonError(.jsModuleEvaluation, msg: "\(error)")
}

}

func resetContext() throws {
Expand All @@ -140,5 +155,29 @@ extension JSEngine: JSEngineProtocol {
func hasContext() -> Bool {
return vm != nil || context != nil
}

private func awaitPromise(_ value: JSValue, in context: JSContext) async throws {
guard value.hasProperty("then") else {
throw PromiseAwaitError.invalidPromise
}
return try await withCheckedThrowingContinuation { continuation in
let resolve: @convention(block) (JSValue) -> Void = { _ in
continuation.resume()
}
let reject: @convention(block) (JSValue) -> Void = { error in
let message = error.toString() ?? "Promise rejected"
continuation.resume(throwing: PromiseAwaitError.rejected(message))
}
let resolveValue = JSValue(object: resolve, in: context)
let rejectValue = JSValue(object: reject, in: context)
_ = value.invokeMethod(
"then", withArguments: [resolveValue as Any, rejectValue as Any])
}
}
}

@_documentation(visibility: private)
enum PromiseAwaitError: Error {
case invalidPromise
case rejected(String)
}
126 changes: 126 additions & 0 deletions Hammerspoon 2/Engine/MultiRootFSModuleLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// MultiRootFSModuleLoader.swift
// Hammerspoon 2
//
// Created by Codex on 28/12/2025.
//

import Foundation
import InternalESModuleForSwiftJavaScriptCore
import JavaScriptCore

@_documentation(visibility: private)
final class MultiRootFSModuleLoader: ESModuleLoaderDelegate {
private let virtualMachine: JSVirtualMachine
private var baseRoots: [URL]
private var moduleCache: [URL: ESModuleScript] = [:]

init(virtualMachine: JSVirtualMachine, baseURLs: [URL]) {
self.virtualMachine = virtualMachine
self.baseRoots = baseURLs.map { $0.standardizedFileURL }
}

func module(for url: URL) throws -> ESModuleScript? {
guard let moduleURL = resolveModuleURL(url: url) else {
return nil
}

if let cached = moduleCache[moduleURL] {
return cached
}

let source = try String(contentsOf: moduleURL, encoding: .utf8)
let module = try ESModuleScript(
withSource: source,
andSourceURL: moduleURL,
andBytecodeCache: nil,
inVirtualMachine: virtualMachine
)
moduleCache[moduleURL] = module
return module
}

func fetchModule(
in context: JSContext,
identifier: String,
resolve: @escaping (ESModuleScript) -> Void,
reject: @escaping (JSValue) -> Void
) {
guard let moduleURL = resolveModuleURL(identifier: identifier) else {
reject(
JSValue(
newErrorFromMessage: "Unable to resolve module identifier: \(identifier)",
in: context))
return
}

do {
guard let module = try module(for: moduleURL) else {
reject(
JSValue(
newErrorFromMessage: "Unable to resolve module URL: \(moduleURL.path)",
in: context))
return
}
resolve(module)
} catch {
reject(
JSValue(
newErrorFromMessage: "Unable to load module \(moduleURL.path): \(error)",
in: context))
}
}

private func resolveModuleURL(identifier: String) -> URL? {
let candidate: URL
if let url = URL(string: identifier), url.scheme != nil {
guard url.isFileURL else {
return nil
}
candidate = url
} else {
let expandedPath = NSString(string: identifier).expandingTildeInPath
if expandedPath.hasPrefix("/") {
candidate = URL(fileURLWithPath: expandedPath)
} else {
for root in baseRoots {
let probe = URL(fileURLWithPath: expandedPath, relativeTo: root)
.standardizedFileURL
if isWithinAllowedRoots(probe)
&& FileManager.default.fileExists(atPath: probe.path)
{
return probe
}
}
return nil
}
}

let normalized = candidate.standardizedFileURL
return isWithinAllowedRoots(normalized) ? normalized : nil
}

private func resolveModuleURL(url: URL) -> URL? {
guard url.isFileURL else {
return nil
}
let normalized = url.standardizedFileURL
return isWithinAllowedRoots(normalized) ? normalized : nil
}

private func isWithinAllowedRoots(_ url: URL) -> Bool {
let path = url.path
for root in baseRoots {
let rootPath = root.path
let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
if path == rootPath || path.hasPrefix(prefix) {
return true
}
}
return false
}

func willEvaluateModule(at key: URL) {

}
}
Loading