rethocker 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -6
- package/bin/rethocker-native +0 -0
- package/package.json +5 -1
- package/src/actions.ts +1 -1
- package/src/assets/logo.png +0 -0
- package/src/cli.ts +39 -21
- package/src/daemon.ts +51 -0
- package/src/register-rule.ts +16 -4
- package/src/rethocker.ts +8 -4
- package/src/rule-engine.ts +8 -2
- package/src/rule-types.ts +12 -1
- package/src/types.ts +9 -0
package/README.md
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="src/assets/logo.png" width="280px" align="center" alt="rethocker logo" />
|
|
3
|
+
<h1 align="center">rethocker</h1>
|
|
4
|
+
<p align="center">
|
|
5
|
+
System-wide key interception and remapping for macOS, in TypeScript.
|
|
6
|
+
</p>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<!--- badges -->
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://github.com/benjamine/rethocker/actions?query=branch%3Amain"><img src="https://github.com/benjamine/rethocker/actions/workflows/publish.yml/badge.svg?branch=main" alt="rethocker CI status" /></a>
|
|
12
|
+
<a href="https://twitter.com/beneidel" rel="nofollow"><img src="https://img.shields.io/badge/created%20by-@beneidel-BACABA.svg" alt="Created by Benjamin Eidelman"></a>
|
|
13
|
+
<a href="https://opensource.org/licenses/MIT" rel="nofollow"><img src="https://img.shields.io/github/license/benjamine/rethocker" alt="License"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/rethocker" rel="nofollow"><img src="https://img.shields.io/npm/dw/rethocker.svg" alt="npm"></a>
|
|
15
|
+
<a href="https://github.com/benjamine/rethocker" rel="nofollow"><img src="https://img.shields.io/github/stars/benjamine/rethocker" alt="stars"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
- Requires **macOS 13+** and **Accessibility permission** (prompted automatically on first run).
|
|
21
|
+
- Built with a native daemon for low-latency key interception, and a TypeScript API for maximum flexibility and AI agent friendliness.
|
|
22
|
+
- Intercept any key or key chord, with per-app scoping and advanced conditions.
|
|
23
|
+
- Remap to other keys, execute shell commands, or call TypeScript handlers with full access to the API.
|
|
24
|
+
- Bundled with convenient actions for common macOS tasks like window management, media control, etc..
|
|
6
25
|
|
|
7
26
|
## Install
|
|
8
27
|
|
|
@@ -14,7 +33,7 @@ brew install rethocker
|
|
|
14
33
|
rethocker install
|
|
15
34
|
```
|
|
16
35
|
|
|
17
|
-
`rethocker install` scaffolds `~/.config/rethocker/default.ts` and registers a LaunchAgent that starts on login and auto-reloads whenever you save the file.
|
|
36
|
+
`rethocker install` scaffolds `~/.config/rethocker/default.ts` (where you write your own rules) and registers a LaunchAgent that starts on login and auto-reloads whenever you save the file.
|
|
18
37
|
|
|
19
38
|
```bash
|
|
20
39
|
rethocker log # live key monitor — see what rethocker captures
|
|
@@ -34,6 +53,8 @@ bun add rethocker
|
|
|
34
53
|
## Usage
|
|
35
54
|
|
|
36
55
|
```ts
|
|
56
|
+
#!/usr/bin/env bun
|
|
57
|
+
|
|
37
58
|
import { actions, Key, rethocker } from "rethocker"
|
|
38
59
|
|
|
39
60
|
const rk = rethocker([
|
package/bin/rethocker-native
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rethocker",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Intercept and remap global keys on macOS — with per-app and key-sequence support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"module": "src/index.ts",
|
|
8
8
|
"types": "src/index.ts",
|
|
9
9
|
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/benjamine/rethocker.git"
|
|
13
|
+
},
|
|
10
14
|
"os": [
|
|
11
15
|
"darwin"
|
|
12
16
|
],
|
package/src/actions.ts
CHANGED
|
Binary file
|
package/src/cli.ts
CHANGED
|
@@ -183,26 +183,44 @@ async function cmdRunConfig(configFile: string) {
|
|
|
183
183
|
|
|
184
184
|
// ─── Help ─────────────────────────────────────────────────────────────────────
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
186
|
+
function buildHelp() {
|
|
187
|
+
const lines = [
|
|
188
|
+
"Usage: rethocker <command>",
|
|
189
|
+
"",
|
|
190
|
+
"Commands:",
|
|
191
|
+
" install Scaffold a config file and set up a background agent that",
|
|
192
|
+
" starts on login and auto-reloads when the config is saved",
|
|
193
|
+
" uninstall Stop the background agent and remove it (keeps your config)",
|
|
194
|
+
" restart Restart the background agent",
|
|
195
|
+
" status Show whether the background agent is running",
|
|
196
|
+
" log Live key monitor — shows keypresses in rethocker rule syntax",
|
|
197
|
+
" so you can copy-paste them directly into your config",
|
|
198
|
+
" help Print this help message",
|
|
199
|
+
"",
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
if (existsSync(PLIST_FILE)) {
|
|
203
|
+
const result = run([
|
|
204
|
+
"launchctl",
|
|
205
|
+
"print",
|
|
206
|
+
`${AGENT_DOMAIN}/${AGENT_LABEL}`,
|
|
207
|
+
]);
|
|
208
|
+
const running = result.ok;
|
|
209
|
+
lines.push(
|
|
210
|
+
`Config: ${tildeify(CONFIG_FILE)} (your rethocker rules are here)`,
|
|
211
|
+
);
|
|
212
|
+
lines.push(`Status: ${running ? "running" : "not running"}`);
|
|
213
|
+
if (!running)
|
|
214
|
+
lines.push('Run "rethocker restart" to start the background agent.');
|
|
215
|
+
} else {
|
|
216
|
+
lines.push(
|
|
217
|
+
`Config: ${tildeify(CONFIG_FILE)} (not found — run "rethocker install")`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
203
220
|
|
|
204
|
-
Docs: ${GITHUB}
|
|
205
|
-
|
|
221
|
+
lines.push("", `Docs: ${GITHUB}`);
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
206
224
|
|
|
207
225
|
// ─── Command dispatch ─────────────────────────────────────────────────────────
|
|
208
226
|
|
|
@@ -240,12 +258,12 @@ switch (subcommand) {
|
|
|
240
258
|
case "help":
|
|
241
259
|
case "--help":
|
|
242
260
|
case "-h":
|
|
243
|
-
console.log(
|
|
261
|
+
console.log(buildHelp());
|
|
244
262
|
break;
|
|
245
263
|
|
|
246
264
|
default:
|
|
247
265
|
if (subcommand) console.error(`Unknown command: "${subcommand}"\n`);
|
|
248
|
-
console.log(
|
|
266
|
+
console.log(buildHelp());
|
|
249
267
|
process.exit(subcommand ? 1 : 0);
|
|
250
268
|
}
|
|
251
269
|
|
package/src/daemon.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { EventEmitter } from "node:events";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
14
15
|
import type { KeyEvent, RethockerEvents } from "./types.ts";
|
|
15
16
|
|
|
16
17
|
// ─── Typed EventEmitter (internal, never exported) ────────────────────────────
|
|
@@ -67,9 +68,33 @@ export function createDaemon(binaryPath: string): Daemon {
|
|
|
67
68
|
[];
|
|
68
69
|
let startPromise: Promise<void> | null = null;
|
|
69
70
|
|
|
71
|
+
// ─── Rule registry (for replay on daemon restart) ─────────────────────────
|
|
72
|
+
// Stores the JSON payload of every active add_rule / add_sequence command,
|
|
73
|
+
// keyed by rule ID. Updated on remove and set_enabled so replayed rules
|
|
74
|
+
// reflect the current state (not the original registration state).
|
|
75
|
+
|
|
76
|
+
const ruleRegistry = new Map<string, Record<string, unknown>>();
|
|
77
|
+
let listenAllEnabled = false;
|
|
78
|
+
let isFirstStart = true;
|
|
79
|
+
|
|
80
|
+
function trackSend(obj: Record<string, unknown>): void {
|
|
81
|
+
const cmd = obj.cmd as string | undefined;
|
|
82
|
+
if (cmd === "add_rule" || cmd === "add_sequence") {
|
|
83
|
+
ruleRegistry.set(obj.id as string, obj);
|
|
84
|
+
} else if (cmd === "remove_rule" || cmd === "remove_sequence") {
|
|
85
|
+
ruleRegistry.delete(obj.id as string);
|
|
86
|
+
} else if (cmd === "set_rule_enabled" || cmd === "set_sequence_enabled") {
|
|
87
|
+
const stored = ruleRegistry.get(obj.id as string);
|
|
88
|
+
if (stored) stored.enabled = obj.enabled;
|
|
89
|
+
} else if (cmd === "listen_all") {
|
|
90
|
+
listenAllEnabled = obj.enabled as boolean;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
// ─── Send / queue ─────────────────────────────────────────────────────────
|
|
71
95
|
|
|
72
96
|
function send(obj: Record<string, unknown>): void {
|
|
97
|
+
trackSend(obj);
|
|
73
98
|
const line = `${JSON.stringify(obj)}\n`;
|
|
74
99
|
if (!_ready) {
|
|
75
100
|
sendQueue.push(line);
|
|
@@ -79,6 +104,19 @@ export function createDaemon(binaryPath: string): Daemon {
|
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
function flushQueue(): void {
|
|
107
|
+
// On restart (not the first start), replay all registered rules first so
|
|
108
|
+
// the new daemon instance is back in the same state as before the crash.
|
|
109
|
+
if (!isFirstStart) {
|
|
110
|
+
for (const payload of ruleRegistry.values()) {
|
|
111
|
+
proc?.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
112
|
+
}
|
|
113
|
+
if (listenAllEnabled) {
|
|
114
|
+
proc?.stdin.write(
|
|
115
|
+
`${JSON.stringify({ cmd: "listen_all", enabled: true })}\n`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
isFirstStart = false;
|
|
82
120
|
for (const line of sendQueue) proc?.stdin.write(line);
|
|
83
121
|
sendQueue = [];
|
|
84
122
|
}
|
|
@@ -192,6 +230,19 @@ export function createDaemon(binaryPath: string): Daemon {
|
|
|
192
230
|
},
|
|
193
231
|
});
|
|
194
232
|
|
|
233
|
+
if (!existsSync(binaryPath)) {
|
|
234
|
+
const err = new Error(
|
|
235
|
+
`rethocker-native binary not found at: ${binaryPath}\n` +
|
|
236
|
+
` If installed via Homebrew, try: brew reinstall rethocker\n` +
|
|
237
|
+
` If installed via npm/bun, try: bun add rethocker\n` +
|
|
238
|
+
` You can also override the path with: rethocker(rules, { binaryPath: "..." })`,
|
|
239
|
+
);
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
reject(err);
|
|
242
|
+
startPromise = null;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
195
246
|
proc = Bun.spawn({
|
|
196
247
|
cmd: [binaryPath],
|
|
197
248
|
stdin: "pipe",
|
package/src/register-rule.ts
CHANGED
|
@@ -39,6 +39,7 @@ function toAppCondition(value: string): AppCondition {
|
|
|
39
39
|
|
|
40
40
|
function buildConditions(rule: {
|
|
41
41
|
app?: string | string[];
|
|
42
|
+
textInput?: boolean;
|
|
42
43
|
conditions?: RuleConditions;
|
|
43
44
|
}): RuleConditions | undefined {
|
|
44
45
|
const base = rule.conditions ?? {};
|
|
@@ -49,10 +50,15 @@ function buildConditions(rule: {
|
|
|
49
50
|
? [...(base.activeApp ?? []), ...[rule.app].flat().map(toAppCondition)]
|
|
50
51
|
: base.activeApp;
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// textInput shorthand merges into conditions (rule-level wins over conditions)
|
|
54
|
+
const textInput = rule.textInput ?? base.textInput;
|
|
55
|
+
|
|
56
|
+
const merged: RuleConditions = { ...base, activeApp, textInput };
|
|
53
57
|
|
|
54
58
|
const hasAny =
|
|
55
|
-
merged.activeApp !== undefined ||
|
|
59
|
+
merged.activeApp !== undefined ||
|
|
60
|
+
merged.runningApps !== undefined ||
|
|
61
|
+
merged.textInput !== undefined;
|
|
56
62
|
|
|
57
63
|
return hasAny ? merged : undefined;
|
|
58
64
|
}
|
|
@@ -158,7 +164,7 @@ function registerHandler(
|
|
|
158
164
|
},
|
|
159
165
|
);
|
|
160
166
|
|
|
161
|
-
|
|
167
|
+
const listener = (seqRuleID: string, eid: string | undefined) => {
|
|
162
168
|
if (eid === eventID) {
|
|
163
169
|
const event: KeyEvent = {
|
|
164
170
|
type: "keydown",
|
|
@@ -170,7 +176,13 @@ function registerHandler(
|
|
|
170
176
|
};
|
|
171
177
|
rule.handler(event);
|
|
172
178
|
}
|
|
173
|
-
}
|
|
179
|
+
};
|
|
180
|
+
emitter.on("sequence", listener);
|
|
181
|
+
const originalRemove = handle.remove;
|
|
182
|
+
handle.remove = () => {
|
|
183
|
+
emitter.off("sequence", listener);
|
|
184
|
+
originalRemove();
|
|
185
|
+
};
|
|
174
186
|
|
|
175
187
|
return handle;
|
|
176
188
|
}
|
package/src/rethocker.ts
CHANGED
|
@@ -58,10 +58,14 @@ export function rethocker(
|
|
|
58
58
|
// ─── Rule handles ─────────────────────────────────────────────────────────
|
|
59
59
|
const handles = new Map<string, RuleHandle | SequenceHandle>();
|
|
60
60
|
|
|
61
|
-
// Start daemon in background
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Start daemon in background. If there's an error listener registered,
|
|
62
|
+
// startup errors are forwarded to it. Otherwise they're silently swallowed
|
|
63
|
+
// (call await rk.start() to catch them explicitly as a rejected promise).
|
|
64
|
+
daemon.start().catch((e: unknown) => {
|
|
65
|
+
if (daemon.emitter.listenerCount("error") > 0) {
|
|
66
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
67
|
+
daemon.emitter.emit("error", "startup_failed", message);
|
|
68
|
+
}
|
|
65
69
|
});
|
|
66
70
|
|
|
67
71
|
function add(toAdd: RethockerRule | RethockerRule[]): void {
|
package/src/rule-engine.ts
CHANGED
|
@@ -85,7 +85,7 @@ export function intercept(
|
|
|
85
85
|
): RuleHandle {
|
|
86
86
|
const eventID = genID("intercept");
|
|
87
87
|
const handle = addRule(send, trigger, { type: "emit", eventID }, opts);
|
|
88
|
-
|
|
88
|
+
const listener = (eid: string, ruleID: string) => {
|
|
89
89
|
if (eid === eventID) {
|
|
90
90
|
handler({
|
|
91
91
|
type: "keydown",
|
|
@@ -96,6 +96,12 @@ export function intercept(
|
|
|
96
96
|
suppressed: true,
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
-
}
|
|
99
|
+
};
|
|
100
|
+
emitter.on("event", listener);
|
|
101
|
+
const originalRemove = handle.remove;
|
|
102
|
+
handle.remove = () => {
|
|
103
|
+
emitter.off("event", listener);
|
|
104
|
+
originalRemove();
|
|
105
|
+
};
|
|
100
106
|
return handle;
|
|
101
107
|
}
|
package/src/rule-types.ts
CHANGED
|
@@ -39,7 +39,18 @@ interface RuleBase {
|
|
|
39
39
|
* app: ["Safari", "Chrome", "Firefox"]
|
|
40
40
|
*/
|
|
41
41
|
app?: string | string[];
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Restrict this rule based on whether a text input field is focused.
|
|
44
|
+
* - `true` → fire only when a text field IS focused
|
|
45
|
+
* - `false` → fire only when NO text field is focused
|
|
46
|
+
* Omit to fire regardless of text input state.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Only fire when NOT in a text field (safe global shortcut)
|
|
50
|
+
* { key: "Ctrl+J", handler: () => {}, textInput: false }
|
|
51
|
+
*/
|
|
52
|
+
textInput?: boolean;
|
|
53
|
+
/** Advanced: full condition control. Merged with `app` and `textInput` if both provided. */
|
|
43
54
|
conditions?: RuleConditions;
|
|
44
55
|
/** Start the rule disabled. */
|
|
45
56
|
disabled?: boolean;
|
package/src/types.ts
CHANGED
|
@@ -50,6 +50,15 @@ export interface RuleConditions {
|
|
|
50
50
|
* Items are OR-ed; omit to not care.
|
|
51
51
|
*/
|
|
52
52
|
runningApps?: AppCondition[];
|
|
53
|
+
/**
|
|
54
|
+
* Restrict this rule based on whether a text input field is focused.
|
|
55
|
+
* - `true` → fire only when a text field IS focused
|
|
56
|
+
* - `false` → fire only when NO text field is focused
|
|
57
|
+
* Omit to fire regardless of text input state.
|
|
58
|
+
*
|
|
59
|
+
* Uses the macOS Accessibility API (cached at 50ms TTL).
|
|
60
|
+
*/
|
|
61
|
+
textInput?: boolean;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|