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
33 changes: 33 additions & 0 deletions Hammerspoon 2.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@
/* End PBXContainerItemProxy section */

/* Begin PBXCopyFilesBuildPhase section */
4F296C603010E91600705CD1 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 12;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
4F947D932F563FCF00DD814E /* Embed XPC Services */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
Expand Down Expand Up @@ -95,6 +104,14 @@
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
4F296C5F3010E8F000705CD1 /* Exceptions for "Hammerspoon 2" folder in "Hammerspoon 2" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Modules/hs.task/README.md,
Modules/hs.ui/HS_UI_GUIDE.md,
);
target = 4F5641822E8333830099EB4C /* Hammerspoon 2 */;
};
4F947DA72F5640D100DD814E /* Exceptions for "HammerspoonOSAScriptHelper" folder in "HammerspoonOSAScriptHelper" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Expand All @@ -111,9 +128,24 @@
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
4F296C643010E96F00705CD1 /* Exceptions for "Hammerspoon 2" folder in "Copy Files" phase from "Hammerspoon 2" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
buildPhase = 4F296C603010E91600705CD1 /* CopyFiles */;
membershipExceptions = (
UserAssets/seed-bundle.js,
UserAssets/seed-package.json,
);
};
/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
4F5641852E8333830099EB4C /* Hammerspoon 2 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F296C5F3010E8F000705CD1 /* Exceptions for "Hammerspoon 2" folder in "Hammerspoon 2" target */,
4F296C643010E96F00705CD1 /* Exceptions for "Hammerspoon 2" folder in "Copy Files" phase from "Hammerspoon 2" target */,
);
path = "Hammerspoon 2";
sourceTree = "<group>";
};
Expand Down Expand Up @@ -259,6 +291,7 @@
4F5641812E8333830099EB4C /* Resources */,
4F947D932F563FCF00DD814E /* Embed XPC Services */,
4FE4C00D2F0000000000000D /* Embed Tools */,
4F296C603010E91600705CD1 /* CopyFiles */,
);
buildRules = (
);
Expand Down
47 changes: 33 additions & 14 deletions Hammerspoon 2/Engine/JSEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class JSEngine {
// This is our startup sequence - install all components in order
do {
try context.install([
.fetch,
ConsoleModuleInstaller(), // console namespace
RequireInstaller(), // require() function
TypeBridgesInstaller(), // HSPoint, HSSize, HSRect, HSFont, HSAlert
Expand Down Expand Up @@ -93,6 +94,11 @@ class JSEngine {
context.globalObject.deleteProperty("hs")
context.globalObject.deleteProperty("console")
context.globalObject.deleteProperty("require")
// Defensively remove require primitives in case require.js failed before its IIFE ran
context.globalObject.deleteProperty("_hs_readFile")
context.globalObject.deleteProperty("_hs_fileExists")
context.globalObject.deleteProperty("_hs_expandPath")
context.globalObject.deleteProperty("_hs_eval")

// Force a synchronous full GC cycle (mark → sweep → finalize) before
// tearing down the VM. JSC's concurrent GC defers ObjC bridge finalizers
Expand Down Expand Up @@ -199,26 +205,39 @@ extension JSEngine: JSEngineProtocol {

struct RequireInstaller: JSContextInstallable {
func install(in context: JSContext) throws {
let require: @convention(block) (String) -> (JSValue?) = { [weak context] path in
let expandedPath = NSString(string: path).expandingTildeInPath
// Four primitives used by require.js. They are captured in its IIFE closure
// and then deleted from global scope, so user code cannot reach them.

// 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 readFile: @convention(block) (String) -> String? = { path in
let expanded = NSString(string: path).expandingTildeInPath
return try? String(contentsOfFile: expanded, encoding: .utf8)
}

let fileURL = URL(fileURLWithPath: expandedPath)
let fileExists: @convention(block) (String) -> Bool = { path in
let expanded = NSString(string: path).expandingTildeInPath
var isDir: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir)
return exists && !isDir.boolValue
}

guard let fileContent = try? String(contentsOfFile: expandedPath, encoding: .utf8) else {
AKError("require(): Unable to read \(expandedPath)")
return nil
}
let expandPath: @convention(block) (String) -> String = { path in
NSString(string: path).expandingTildeInPath
}

return context?.evaluateScript(fileContent, withSourceURL: fileURL)
let evalScript: @convention(block) (String, String) -> JSValue? = { [weak context] source, filename in
guard let context else { return nil }
return context.evaluateScript(source, withSourceURL: URL(fileURLWithPath: filename))
}

context.setObject(require, forKeyedSubscript: "require" as NSString)
context.setObject(readFile, forKeyedSubscript: "_hs_readFile" as NSString)
context.setObject(fileExists, forKeyedSubscript: "_hs_fileExists" as NSString)
context.setObject(expandPath, forKeyedSubscript: "_hs_expandPath" as NSString)
context.setObject(evalScript, forKeyedSubscript: "_hs_eval" as NSString)

guard let requireURL = Bundle.main.url(forResource: "require", withExtension: "js") else {
throw HammerspoonError(.vmCreation, msg: "require.js not found in bundle")
}
context.evaluateScript(try String(contentsOf: requireURL, encoding: .utf8), withSourceURL: requireURL)
}
}

153 changes: 153 additions & 0 deletions Hammerspoon 2/Engine/require.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use strict";

// Hammerspoon 2 — CommonJS module system
//
// Four primitives are installed by Swift before this file is evaluated.
// They are captured in the IIFE closure and then deleted from global scope
// so user code cannot reach or replace them.
//
// Spoons are packages installed to ~/.config/hammerspoon2/Spoons/<Name>/
// and loaded with require('Name') or require('Name/lib/something').

(function () {
const _readFile = globalThis._hs_readFile;
const _fileExists = globalThis._hs_fileExists;
const _expandPath = globalThis._hs_expandPath;
const _evalScript = globalThis._hs_eval;

delete globalThis._hs_readFile;
delete globalThis._hs_fileExists;
delete globalThis._hs_expandPath;
delete globalThis._hs_eval;

const SPOONS_DIR = _expandPath("~/.config/hammerspoon2/Spoons");

// Resolved absolute path → { exports, loaded, filename, id }
const _cache = Object.create(null);

function dirname(p) {
const i = p.lastIndexOf('/');
return i > 0 ? p.slice(0, i) : '/';
}

// Concatenate base dir and a possibly-relative segment, then normalise . and ..
function joinPath(base, rel) {
const parts = (base + '/' + rel).split('/');
const out = [];
for (const p of parts) {
if (p === '..') out.pop();
else if (p !== '.' && p !== '') out.push(p);
}
return '/' + out.join('/');
}

// Probe path as-is, then with .js, .json, /index.js suffixes
function tryExtensions(base) {
if (_fileExists(base)) return base;
if (_fileExists(base + '.js')) return base + '.js';
if (_fileExists(base + '.json')) return base + '.json';
if (_fileExists(base + '/index.js')) return base + '/index.js';
return null;
}

// Bare specifier: look up ~/.config/hammerspoon2/Spoons/<id>/
function resolveSpoon(id) {
const spoonDir = SPOONS_DIR + '/' + id;
const spoonJson = spoonDir + '/spoon.json';
if (_fileExists(spoonJson)) {
const src = _readFile(spoonJson);
if (src) {
try {
const meta = JSON.parse(src);
if (meta.main) {
const resolved = tryExtensions(spoonDir + '/' + meta.main);
if (resolved) return resolved;
}
} catch (_) {}
}
}
return tryExtensions(spoonDir + '/index') || tryExtensions(spoonDir);
}

function resolvePath(id, fromFile) {
if (id.startsWith('/')) {
return tryExtensions(id);
}
if (id.startsWith('./') || id.startsWith('../')) {
return tryExtensions(joinPath(dirname(fromFile), id));
}
if (id.startsWith('~/')) {
return tryExtensions(_expandPath(id));
}
return resolveSpoon(id);
}

function makeRequire(currentFile) {
function require(id) {
const resolved = resolvePath(String(id), currentFile);
if (!resolved) {
throw new Error("Cannot find module '" + id + "' (from: " + currentFile + ")");
}

// Return cached exports — handles circular dependencies by returning the
// partially-populated exports object that was registered before execution began.
if (_cache[resolved]) {
return _cache[resolved].exports;
}

const src = _readFile(resolved);
if (src == null) {
throw new Error("Cannot read module '" + resolved + "'");
}

const mod = { exports: {}, filename: resolved, id: resolved, loaded: false };
_cache[resolved] = mod;

if (resolved.endsWith('.json')) {
try {
mod.exports = JSON.parse(src);
mod.loaded = true;
return mod.exports;
} catch (e) {
delete _cache[resolved];
throw e;
}
}
Comment thread
cmsj marked this conversation as resolved.

// Wrap in CommonJS function so each module gets its own scope.
// The newline keeps source line numbers accurate (off by one on line 1 only).
const wrapper = "(function(module,exports,require,__filename,__dirname){\n" + src + "\n})";
const fn = _evalScript(wrapper, resolved);

if (typeof fn !== 'function') {
delete _cache[resolved];
throw new Error("Failed to compile '" + resolved + "' (syntax error?)");
}

try {
fn(mod, mod.exports, makeRequire(resolved), resolved, dirname(resolved));
} catch (e) {
delete _cache[resolved];
throw e;
}

mod.loaded = true;
return mod.exports;
}

require.resolve = function (id) {
const resolved = resolvePath(String(id), currentFile);
if (!resolved) {
throw new Error("Cannot resolve '" + id + "' (from: " + currentFile + ")");
}
return resolved;
};

require.cache = _cache;
return require;
}

// The top-level require treats ~/.config/hammerspoon2/ as the base directory so that
// require('./utils') in a user's init.js resolves to ~/.config/hammerspoon2/utils.js.
globalThis.require = makeRequire(_expandPath("~/.config/hammerspoon2") + "/<init>");
}());
1 change: 1 addition & 0 deletions Hammerspoon 2/Lifecycle/Hammerspoon_2App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}


func application(_ application: NSApplication, open urls: [URL]) {
for url in urls {
URLEventDispatcher.shared.dispatch(url)
Expand Down
58 changes: 50 additions & 8 deletions Hammerspoon 2/Managers/ManagerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,9 @@ class ManagerManager {
func boot() throws {
try engine.resetContext()

let configDir = settings.configLocation.deletingLastPathComponent()
var configDirExists = ObjCBool(booleanLiteral: false)
unsafe _ = fileSystem.fileExists(atPath: configDir.path, isDirectory: &configDirExists)

if !configDirExists.boolValue {
AKError("Configuration directory does not exist at: \(configDir.path)")
return
}
setupConfigDirectory()

let configDir = settings.configLocation.deletingLastPathComponent()
FileManager.default.changeCurrentDirectoryPath(configDir.path)

if !fileSystem.fileExists(atPath: settings.configLocation.path) {
Expand All @@ -71,6 +65,54 @@ class ManagerManager {
try engine.evalFromURL(settings.configLocation, wrapInIIFE: false)
}

// Creates the config directory if absent, then seeds any bundled UserAsset
// files that are not already present (so user customisations are preserved).
private func setupConfigDirectory() {
let configDir = settings.configLocation.deletingLastPathComponent()
let fm = FileManager.default

guard !fileSystem.fileExists(atPath: configDir.path) else {
// Config directory exists, take no further action
return
}

do {
try fm.createDirectory(at: configDir, withIntermediateDirectories: true)
AKDebug("Created config directory: \(configDir.path)")
} catch {
AKError("Failed to create config directory at \(configDir.path): \(error.localizedDescription)")
return
}
Comment thread
cmsj marked this conversation as resolved.

guard let sharedSupport = Bundle.main.sharedSupportURL else {
AKError("Could not locate SharedSupport directory in bundle")
return
}

let seedFiles: [(bundleName: String, destName: String)] = [
("seed-package.json", "package.json"),
("seed-bundle.js", "bundle.js"),
]

for (bundleName, destName) in seedFiles {
let dest = configDir.appendingPathComponent(destName)
guard !fileSystem.fileExists(atPath: dest.path) else { continue }

let src = sharedSupport.appendingPathComponent(bundleName)
guard fileSystem.fileExists(atPath: src.path) else {
AKError("Seed file missing from bundle: \(bundleName)")
continue
}

do {
try fm.copyItem(at: src, to: dest)
AKDebug("Seeded \(destName) into config directory")
} catch {
AKError("Failed to copy \(destName) to config directory: \(error.localizedDescription)")
}
}
}

func shutdown() {
engine.shutdown()
NSApp.terminate(self)
Expand Down
Loading
Loading