rethocker 0.2.2 → 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/bin/rethocker-native +0 -0
- package/package.json +5 -1
- package/src/actions.ts +1 -1
- 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/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
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 ─────────────────────────────────────────────────────────────────
|