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.
Binary file
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "rethocker",
3
- "version": "0.2.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
@@ -71,7 +71,7 @@ tell application "Finder"
71
71
  end tell
72
72
  set screenW to item 3 of screenBounds
73
73
  set screenH to item 4 of screenBounds
74
- set menuBarH to 30
74
+ set menuBarH to item 2 of screenBounds
75
75
  set pos to ${posExpr}
76
76
  set sz to ${sizeExpr}
77
77
  ${targetBlock}
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",
@@ -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
- const merged: RuleConditions = { ...base, activeApp };
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 || merged.runningApps !== 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
- emitter.on("sequence", (seqRuleID, eid) => {
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; errors surface via rk.on("error") or await rk.start()
62
- // Silent errors surface via rk.on("error") or await rk.start()
63
- daemon.start().catch((_e: unknown) => {
64
- /* intentional */
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 {
@@ -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
- emitter.on("event", (eid, ruleID) => {
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
- /** Advanced: full condition control. Merged with `app` if both provided. */
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 ─────────────────────────────────────────────────────────────────