rethocker 0.0.2 → 0.1.1

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/src/actions.ts ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Built-in macOS action helpers.
3
+ *
4
+ * Each function returns a shell command string suitable for use in the
5
+ * `execute` field of a rule. Pass an array to `execute` to run multiple
6
+ * actions together.
7
+ *
8
+ * @example
9
+ * import { rethocker, actions } from "rethocker"
10
+ *
11
+ * const rk = rethocker([
12
+ * { key: "Ctrl+Left", execute: actions.window.halfLeft() },
13
+ * { key: "Ctrl+F", execute: actions.focusMode.toggle() },
14
+ * { key: "Ctrl+Shift+S", execute: actions.app.focus("Slack") },
15
+ * { key: "F8", execute: actions.media.playPause() },
16
+ * // Multiple actions at once:
17
+ * { key: "Ctrl+Alt+L", execute: [actions.app.focus("Slack"), actions.focusMode.on("Work")] },
18
+ * ])
19
+ */
20
+
21
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
22
+
23
+ /** Wrap an AppleScript string into a one-liner osascript call. */
24
+ function osascript(script: string): string {
25
+ // Escape single quotes in the script for shell safety
26
+ const escaped = script.replace(/'/g, "'\\''");
27
+ return `osascript -e '${escaped}'`;
28
+ }
29
+
30
+ /**
31
+ * Multi-line AppleScript — written to a temp file to avoid shell quoting issues.
32
+ * Uses a heredoc so the script is passed cleanly regardless of content.
33
+ */
34
+ function osascriptMultiline(script: string): string {
35
+ // Use process substitution to avoid temp file cleanup concerns
36
+ return `osascript << 'RETHOCKER_EOF'\n${script}\nRETHOCKER_EOF`;
37
+ }
38
+
39
+ // ─── Window layout ────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * AppleScript that positions a window on screen.
43
+ * Gets screen bounds dynamically so it works on any display size.
44
+ * Optionally targets a specific app by name; defaults to the frontmost app.
45
+ * If the app is not running, it is launched first.
46
+ */
47
+ function windowScript(
48
+ posExpr: string, // e.g. "{0, menuBarH}"
49
+ sizeExpr: string, // e.g. "{screenW / 2, screenH - menuBarH}"
50
+ appName?: string,
51
+ ): string {
52
+ const activateBlock = appName
53
+ ? `tell application "${appName}" to activate\ndelay 0.1`
54
+ : "";
55
+ const targetBlock = appName
56
+ ? `tell application "${appName}"
57
+ set bounds of window 1 to {item 1 of pos, item 2 of pos, (item 1 of pos) + (item 1 of sz), (item 2 of pos) + (item 2 of sz)}
58
+ end tell`
59
+ : `tell application "System Events"
60
+ set frontApp to name of first application process whose frontmost is true
61
+ tell process frontApp
62
+ set position of window 1 to pos
63
+ set size of window 1 to sz
64
+ end tell
65
+ end tell`;
66
+
67
+ return osascriptMultiline(
68
+ `
69
+ ${activateBlock}
70
+ tell application "Finder"
71
+ set screenBounds to bounds of window of desktop
72
+ end tell
73
+ set screenW to item 3 of screenBounds
74
+ set screenH to item 4 of screenBounds
75
+ set menuBarH to 30
76
+ set pos to ${posExpr}
77
+ set sz to ${sizeExpr}
78
+ ${targetBlock}
79
+ `.trim(),
80
+ );
81
+ }
82
+
83
+ export type AppTarget = string | undefined;
84
+
85
+ export const window = {
86
+ /** Left half of screen */
87
+ halfLeft: (app?: AppTarget) =>
88
+ windowScript("{0, menuBarH}", "{screenW / 2, screenH - menuBarH}", app),
89
+
90
+ /** Right half of screen */
91
+ halfRight: (app?: AppTarget) =>
92
+ windowScript(
93
+ "{screenW / 2, menuBarH}",
94
+ "{screenW / 2, screenH - menuBarH}",
95
+ app,
96
+ ),
97
+
98
+ /** Top half of screen */
99
+ halfTop: (app?: AppTarget) =>
100
+ windowScript("{0, menuBarH}", "{screenW, (screenH - menuBarH) / 2}", app),
101
+
102
+ /** Bottom half of screen */
103
+ halfBottom: (app?: AppTarget) =>
104
+ windowScript(
105
+ "{0, menuBarH + (screenH - menuBarH) / 2}",
106
+ "{screenW, (screenH - menuBarH) / 2}",
107
+ app,
108
+ ),
109
+
110
+ /** Left third of screen */
111
+ thirdLeft: (app?: AppTarget) =>
112
+ windowScript("{0, menuBarH}", "{screenW / 3, screenH - menuBarH}", app),
113
+
114
+ /** Center third of screen */
115
+ thirdCenter: (app?: AppTarget) =>
116
+ windowScript(
117
+ "{screenW / 3, menuBarH}",
118
+ "{screenW / 3, screenH - menuBarH}",
119
+ app,
120
+ ),
121
+
122
+ /** Right third of screen */
123
+ thirdRight: (app?: AppTarget) =>
124
+ windowScript(
125
+ "{(screenW / 3) * 2, menuBarH}",
126
+ "{screenW / 3, screenH - menuBarH}",
127
+ app,
128
+ ),
129
+
130
+ /** Top-left quadrant */
131
+ quarterTopLeft: (app?: AppTarget) =>
132
+ windowScript(
133
+ "{0, menuBarH}",
134
+ "{screenW / 2, (screenH - menuBarH) / 2}",
135
+ app,
136
+ ),
137
+
138
+ /** Top-right quadrant */
139
+ quarterTopRight: (app?: AppTarget) =>
140
+ windowScript(
141
+ "{screenW / 2, menuBarH}",
142
+ "{screenW / 2, (screenH - menuBarH) / 2}",
143
+ app,
144
+ ),
145
+
146
+ /** Bottom-left quadrant */
147
+ quarterBottomLeft: (app?: AppTarget) =>
148
+ windowScript(
149
+ "{0, menuBarH + (screenH - menuBarH) / 2}",
150
+ "{screenW / 2, (screenH - menuBarH) / 2}",
151
+ app,
152
+ ),
153
+
154
+ /** Bottom-right quadrant */
155
+ quarterBottomRight: (app?: AppTarget) =>
156
+ windowScript(
157
+ "{screenW / 2, menuBarH + (screenH - menuBarH) / 2}",
158
+ "{screenW / 2, (screenH - menuBarH) / 2}",
159
+ app,
160
+ ),
161
+
162
+ /** Maximize (fill the screen below the menu bar) */
163
+ maximize: (app?: AppTarget) =>
164
+ windowScript("{0, menuBarH}", "{screenW, screenH - menuBarH}", app),
165
+ };
166
+
167
+ // ─── App management ───────────────────────────────────────────────────────────
168
+
169
+ export const app = {
170
+ /**
171
+ * Open the app if not running, then bring it to the foreground.
172
+ *
173
+ * @example
174
+ * execute: actions.app.focus("Slack")
175
+ * execute: actions.app.focus("com.tinyspeck.slackmacgap") // bundle ID also works
176
+ */
177
+ focus: (nameOrBundleID: string): string => {
178
+ // Bundle IDs contain dots — use `open -b` for them, `open -a` for names
179
+ const isBundleID = nameOrBundleID.includes(".");
180
+ return isBundleID
181
+ ? `open -b '${nameOrBundleID}'`
182
+ : `open -a '${nameOrBundleID}'`;
183
+ },
184
+
185
+ /**
186
+ * Quit an app by name or bundle ID.
187
+ *
188
+ * @example
189
+ * execute: actions.app.quit("Slack")
190
+ */
191
+ quit: (nameOrBundleID: string): string => {
192
+ const isBundleID = nameOrBundleID.includes(".");
193
+ return isBundleID
194
+ ? osascript(`tell application id "${nameOrBundleID}" to quit`)
195
+ : osascript(`tell application "${nameOrBundleID}" to quit`);
196
+ },
197
+ };
198
+
199
+ // ─── Shortcuts app ────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Run a named shortcut from the macOS Shortcuts app.
203
+ *
204
+ * @example
205
+ * execute: actions.shortcut("Morning Routine")
206
+ */
207
+ export function shortcut(name: string): string {
208
+ return `shortcuts run '${name}'`;
209
+ }
210
+
211
+ // ─── Media ────────────────────────────────────────────────────────────────────
212
+
213
+ export const media = {
214
+ /** Toggle play / pause in the active media app. */
215
+ playPause: (): string =>
216
+ osascript(`tell application "System Events" to key code 100`),
217
+
218
+ /** Skip to next track. */
219
+ next: (): string =>
220
+ osascript(`tell application "System Events" to key code 101`),
221
+
222
+ /** Go to previous track. */
223
+ previous: (): string =>
224
+ osascript(`tell application "System Events" to key code 98`),
225
+
226
+ /** Toggle system audio mute. */
227
+ mute: (): string =>
228
+ osascript(
229
+ `set volume output muted not (output muted of (get volume settings))`,
230
+ ),
231
+
232
+ /** Set system volume (0–100). */
233
+ setVolume: (level: number): string =>
234
+ osascript(`set volume output volume ${Math.max(0, Math.min(100, level))}`),
235
+
236
+ /** Increase system volume by a step (default 10). */
237
+ volumeUp: (step = 10): string =>
238
+ osascript(
239
+ `set vol to output volume of (get volume settings)\nset volume output volume (vol + ${step})`,
240
+ ),
241
+
242
+ /** Decrease system volume by a step (default 10). */
243
+ volumeDown: (step = 10): string =>
244
+ osascript(
245
+ `set vol to output volume of (get volume settings)\nset volume output volume (vol - ${step})`,
246
+ ),
247
+ };
248
+
249
+ // ─── System ───────────────────────────────────────────────────────────────────
250
+
251
+ export const system = {
252
+ /** Put the Mac to sleep immediately. */
253
+ sleep: (): string => osascript(`tell application "System Events" to sleep`),
254
+
255
+ /** Lock the screen. */
256
+ lockScreen: (): string =>
257
+ `/System/Library/CoreServices/Menu\\ Extras/User.menu/Contents/Resources/CGSession -suspend`,
258
+
259
+ /** Show the desktop (Mission Control: show desktop). */
260
+ showDesktop: (): string =>
261
+ osascript(
262
+ `tell application "System Events" to key code 103 using {command down}`,
263
+ ),
264
+
265
+ /** Open Mission Control. */
266
+ missionControl: (): string =>
267
+ osascript(`tell application "Mission Control" to launch`),
268
+
269
+ /** Empty the Trash. */
270
+ emptyTrash: (): string =>
271
+ osascript(`tell application "Finder" to empty trash`),
272
+ };
273
+
274
+ // ─── Top-level actions object ─────────────────────────────────────────────────
275
+
276
+ export const actions = {
277
+ window,
278
+ app,
279
+ shortcut,
280
+ media,
281
+ system,
282
+ };
package/src/daemon.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Daemon — manages the native rethocker-native subprocess lifecycle and IPC.
3
+ *
4
+ * Responsibilities:
5
+ * - Spawn / stop the native binary
6
+ * - Buffer commands sent before ready, flush on startup
7
+ * - Parse newline-delimited JSON from stdout and emit typed events
8
+ * - Pipe stderr to process.stderr for visibility
9
+ *
10
+ * This module knows nothing about rules — it only speaks raw IPC JSON.
11
+ */
12
+
13
+ import { EventEmitter } from "node:events";
14
+ import type { DeviceInfo, KeyEvent, RethockerEvents } from "./types.ts";
15
+
16
+ // ─── Typed EventEmitter (internal, never exported) ────────────────────────────
17
+
18
+ type Listener = (...args: unknown[]) => void;
19
+
20
+ export class TypedEmitter extends EventEmitter {
21
+ override on<K extends keyof RethockerEvents>(
22
+ event: K,
23
+ listener: (...args: RethockerEvents[K]) => void,
24
+ ): this {
25
+ return super.on(event as string, listener as Listener);
26
+ }
27
+ override once<K extends keyof RethockerEvents>(
28
+ event: K,
29
+ listener: (...args: RethockerEvents[K]) => void,
30
+ ): this {
31
+ return super.once(event as string, listener as Listener);
32
+ }
33
+ override off<K extends keyof RethockerEvents>(
34
+ event: K,
35
+ listener: (...args: RethockerEvents[K]) => void,
36
+ ): this {
37
+ return super.off(event as string, listener as Listener);
38
+ }
39
+ override emit<K extends keyof RethockerEvents>(
40
+ event: K,
41
+ ...args: RethockerEvents[K]
42
+ ): boolean {
43
+ return super.emit(event as string, ...args);
44
+ }
45
+ }
46
+
47
+ // ─── Daemon ───────────────────────────────────────────────────────────────────
48
+
49
+ export interface Daemon {
50
+ readonly emitter: TypedEmitter;
51
+ /** Send a raw IPC command. Buffered if the daemon isn't ready yet. */
52
+ send(obj: Record<string, unknown>): void;
53
+ /** Start the daemon. Returns the same promise on concurrent calls. */
54
+ start(): Promise<void>;
55
+ stop(): Promise<void>;
56
+ unref(): void;
57
+ readonly ready: boolean;
58
+ }
59
+
60
+ export function createDaemon(binaryPath: string): Daemon {
61
+ const emitter = new TypedEmitter();
62
+ let proc: Bun.Subprocess<"pipe", "pipe", "pipe"> | null = null;
63
+ let _ready = false;
64
+ let buffer = "";
65
+ let sendQueue: string[] = [];
66
+ let pendingReady: Array<{ resolve: () => void; reject: (e: Error) => void }> =
67
+ [];
68
+ let startPromise: Promise<void> | null = null;
69
+
70
+ // ─── Send / queue ─────────────────────────────────────────────────────────
71
+
72
+ function send(obj: Record<string, unknown>): void {
73
+ const line = `${JSON.stringify(obj)}\n`;
74
+ if (!_ready) {
75
+ sendQueue.push(line);
76
+ return;
77
+ }
78
+ proc?.stdin.write(line);
79
+ }
80
+
81
+ function flushQueue(): void {
82
+ for (const line of sendQueue) proc?.stdin.write(line);
83
+ sendQueue = [];
84
+ }
85
+
86
+ // ─── Message parsing ──────────────────────────────────────────────────────
87
+
88
+ function handleMessage(line: string): void {
89
+ let msg: Record<string, unknown>;
90
+ try {
91
+ msg = JSON.parse(line) as Record<string, unknown>;
92
+ } catch {
93
+ return;
94
+ }
95
+ switch (msg.type) {
96
+ case "ready":
97
+ _ready = true;
98
+ flushQueue();
99
+ for (const p of pendingReady) p.resolve();
100
+ pendingReady = [];
101
+ emitter.emit("ready");
102
+ break;
103
+ case "keydown":
104
+ case "keyup":
105
+ case "flags":
106
+ emitter.emit("key", msg as unknown as KeyEvent);
107
+ break;
108
+ case "matched":
109
+ if (msg.eventID) {
110
+ emitter.emit("event", msg.eventID as string, msg.ruleID as string);
111
+ }
112
+ break;
113
+ case "sequence_matched":
114
+ emitter.emit(
115
+ "sequence",
116
+ msg.ruleID as string,
117
+ msg.eventID as string | undefined,
118
+ );
119
+ break;
120
+ case "devices":
121
+ emitter.emit("devices", msg.devices as DeviceInfo[]);
122
+ break;
123
+ case "error":
124
+ if (msg.code === "accessibility_denied") {
125
+ emitter.emit("accessibilityDenied");
126
+ } else {
127
+ emitter.emit("error", msg.code as string, msg.message as string);
128
+ }
129
+ break;
130
+ }
131
+ }
132
+
133
+ // ─── Read loops ───────────────────────────────────────────────────────────
134
+
135
+ async function readStdout(): Promise<void> {
136
+ const reader = proc?.stdout.getReader();
137
+ if (!reader) return;
138
+ const decoder = new TextDecoder();
139
+ try {
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ buffer += decoder.decode(value, { stream: true });
144
+ const lines = buffer.split("\n");
145
+ buffer = lines.pop() ?? "";
146
+ for (const line of lines) {
147
+ if (line.trim()) handleMessage(line);
148
+ }
149
+ }
150
+ } catch {
151
+ // process exited — handled by the exited promise
152
+ }
153
+ }
154
+
155
+ async function readStderr(): Promise<void> {
156
+ const reader = proc?.stderr.getReader();
157
+ if (!reader) return;
158
+ const decoder = new TextDecoder();
159
+ let buf = "";
160
+ try {
161
+ while (true) {
162
+ const { done, value } = await reader.read();
163
+ if (done) break;
164
+ buf += decoder.decode(value, { stream: true });
165
+ const lines = buf.split("\n");
166
+ buf = lines.pop() ?? "";
167
+ for (const line of lines) {
168
+ if (line.trim()) process.stderr.write(`[rethocker-native] ${line}\n`);
169
+ }
170
+ }
171
+ } catch {
172
+ // process exited
173
+ }
174
+ }
175
+
176
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
177
+
178
+ function start(): Promise<void> {
179
+ if (startPromise) return startPromise;
180
+
181
+ startPromise = new Promise<void>((resolve, reject) => {
182
+ const timeout = setTimeout(() => {
183
+ reject(new Error("rethocker-native startup timed out after 10s"));
184
+ }, 10_000);
185
+
186
+ pendingReady.push({
187
+ resolve: () => {
188
+ clearTimeout(timeout);
189
+ resolve();
190
+ },
191
+ reject: (e) => {
192
+ clearTimeout(timeout);
193
+ reject(e);
194
+ },
195
+ });
196
+
197
+ proc = Bun.spawn({
198
+ cmd: [binaryPath],
199
+ stdin: "pipe",
200
+ stdout: "pipe",
201
+ stderr: "pipe",
202
+ });
203
+
204
+ readStdout();
205
+ readStderr();
206
+
207
+ proc.exited.then((code) => {
208
+ _ready = false;
209
+ startPromise = null;
210
+ emitter.emit("exit", code);
211
+ const err = new Error(
212
+ `rethocker-native exited unexpectedly (code ${code})`,
213
+ );
214
+ for (const p of pendingReady) p.reject(err);
215
+ pendingReady = [];
216
+ });
217
+ });
218
+
219
+ return startPromise;
220
+ }
221
+
222
+ async function stop(): Promise<void> {
223
+ _ready = false;
224
+ startPromise = null;
225
+ proc?.kill();
226
+ await proc?.exited;
227
+ proc = null;
228
+ }
229
+
230
+ function unref(): void {
231
+ proc?.unref();
232
+ }
233
+
234
+ return {
235
+ emitter,
236
+ send,
237
+ start,
238
+ stop,
239
+ unref,
240
+ get ready() {
241
+ return _ready;
242
+ },
243
+ };
244
+ }
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "bun:test";
2
+ import { actions, Key, KeyModifier, rethocker } from "./index.ts";
3
+
4
+ test("Key constants are strings", () => {
5
+ expect(Key.capsLock).toBe("capsLock");
6
+ expect(Key.escape).toBe("escape");
7
+ expect(Key.brightnessDown).toBe("brightnessDown");
8
+ expect(Key.volumeUp).toBe("volumeUp");
9
+ expect(Key.numpadEnter).toBe("numpadEnter");
10
+ });
11
+
12
+ test("KeyModifier constants are valid modifier strings", () => {
13
+ expect(KeyModifier.Cmd).toBe("cmd");
14
+ expect(KeyModifier.Shift).toBe("shift");
15
+ expect(KeyModifier.Alt).toBe("alt");
16
+ });
17
+
18
+ test("Key interpolation produces valid key strings", () => {
19
+ expect(`${Key.capsLock}`).toBe("capsLock");
20
+ expect(`Cmd+${Key.v}`).toBe("Cmd+v");
21
+ expect(`${Key.brightnessDown} ${Key.brightnessUp}`).toBe(
22
+ "brightnessDown brightnessUp",
23
+ );
24
+ });
25
+
26
+ test("actions.window returns shell command strings", () => {
27
+ expect(typeof actions.window.halfLeft()).toBe("string");
28
+ expect(typeof actions.window.halfRight()).toBe("string");
29
+ expect(typeof actions.window.maximize()).toBe("string");
30
+ expect(typeof actions.window.halfLeft("Figma")).toBe("string");
31
+ });
32
+
33
+ test("actions.app returns shell command strings", () => {
34
+ expect(typeof actions.app.focus("Slack")).toBe("string");
35
+ expect(typeof actions.app.focus("com.tinyspeck.slackmacgap")).toBe("string");
36
+ expect(typeof actions.app.quit("Slack")).toBe("string");
37
+ });
38
+
39
+ test("actions.media returns shell command strings", () => {
40
+ expect(typeof actions.media.playPause()).toBe("string");
41
+ expect(typeof actions.media.mute()).toBe("string");
42
+ expect(typeof actions.media.setVolume(50)).toBe("string");
43
+ });
44
+
45
+ test("actions.system returns shell command strings", () => {
46
+ expect(typeof actions.system.sleep()).toBe("string");
47
+ expect(typeof actions.system.lockScreen()).toBe("string");
48
+ });
49
+
50
+ test("rethocker() creates a handle with expected methods", () => {
51
+ const rk = rethocker();
52
+ expect(typeof rk.add).toBe("function");
53
+ expect(typeof rk.remove).toBe("function");
54
+ expect(typeof rk.enable).toBe("function");
55
+ expect(typeof rk.disable).toBe("function");
56
+ expect(typeof rk.on).toBe("function");
57
+ expect(typeof rk.start).toBe("function");
58
+ expect(typeof rk.stop).toBe("function");
59
+ expect(typeof rk.unref).toBe("function");
60
+ expect(typeof rk.execute).toBe("function");
61
+ // Stop immediately — don't actually start the daemon in tests
62
+ rk.stop();
63
+ });
package/src/index.ts CHANGED
@@ -1 +1,8 @@
1
- console.log("Hello via Bun!");
1
+ // ─── Public API ───────────────────────────────────────────────────────────────
2
+
3
+ export { actions } from "./actions.ts";
4
+ export { Key, KeyModifier } from "./keys.ts";
5
+ export { rethocker } from "./rethocker.ts";
6
+ export type { RethockerHandle, RethockerRule } from "./rule-types.ts";
7
+
8
+ export type { KeyEvent, Modifier } from "./types.ts";