1import { Events } from "@clo/lib/Events.ts";
2import * as console from "@clo/lib/log.ts";
3import type { Dispose } from "@clo/lib/ts.ts";
4import * as fs from "node:fs/promises";
5import * as path from "node:path";
6import * as url from "node:url";
7import { Dialpad } from "./Dialpad.ts";
8import { Keypad } from "./Keypad.ts";
9import { KeypadSurface } from "./KeypadUI.ts";
10import { Mac } from "./Mac.ts";
11
12export interface Configure {
13 /** 9 LCD keys + back/forward. Events/screens are only active when the app is focused. */
14 keypad: KeypadSurface;
15 /** One dial, one knob, four buttons. Events are only active when the app is focused. */
16 dialpad: AppDialpad;
17 /** Not intended to listen for events. */
18 mac: Mac;
19 /** Control the application itself */
20 app: Events<AppEvents>;
21}
22
23export interface AppConfig {
24 bundle: Mac.BundleId;
25 configure: (x: Configure) => void;
26}
27
28interface AppInstance {
29 bundle: Mac.BundleId;
30 keypad: KeypadSurface;
31 dialpad: AppDialpad;
32 app: Events<AppEvents>;
33}
34
35interface AppDefinition {
36 bundle: Mac.BundleId;
37 load: () => Promise<AppConfig>;
38}
39
40export type AppEvents = {
41 "focus": [];
42 "blur": [];
43};
44
45export function forApp(
46 bundle: Mac.BundleId,
47 configure: AppConfig["configure"],
48): AppConfig {
49 return { bundle, configure };
50}
51
52/** Per-app proxy that only forwards Dialpad events while its app is focused. */
53export class AppDialpad extends Events<Dialpad.EventMap> {
54 #source: Dialpad;
55 #enabled = false;
56
57 constructor(source: Dialpad) {
58 super();
59 this.#source = source;
60 source.onAny((event, args) => {
61 if (this.#enabled) {
62 this.emit(
63 event,
64 ...args as Dialpad.EventMap[keyof Dialpad.EventMap],
65 );
66 }
67 });
68 }
69
70 get enabled() {
71 return this.#enabled;
72 }
73
74 set enabled(enabled: boolean) {
75 this.#enabled = enabled;
76 }
77
78 onPress(button: Dialpad.Button, listener: () => void): Dispose {
79 return this.on("keypress", (code) => {
80 if (button === code) listener();
81 });
82 }
83}
84
85async function loadConfigs(configDir: string) {
86 const entries = await fs.readdir(configDir, { withFileTypes: true });
87 const modules = entries
88 .filter((entry) => entry.isFile())
89 .filter((entry) => path.extname(entry.name) === ".ts")
90 .sort((left, right) => left.name.localeCompare(right.name));
91
92 const definitions: AppDefinition[] = [];
93
94 for (const entry of modules) {
95 const filePath = path.join(configDir, entry.name);
96 const moduleUrl = url.pathToFileURL(filePath).href;
97 let configPromise: Promise<AppConfig> | null = null;
98 const load = () => {
99 configPromise ??= loadConfig(moduleUrl);
100 return configPromise;
101 };
102
103 const source = await fs.readFile(filePath, "utf8");
104 const bundle = readBundleId(source) ?? (await load()).bundle;
105 definitions.push({ bundle, load });
106 }
107
108 return definitions;
109}
110
111export async function runConfigs(dir: string) {
112 const definitions = await loadConfigs(dir);
113
114 const [mac, keypad, dialpad] = await Promise.all([
115 Mac.open(),
116 Keypad.open(),
117 Dialpad.open(),
118 ]);
119
120 if (!keypad.connected) {
121 console.warn("MX Creative Keypad is not connected (connect it over USB-C)");
122 }
123 keypad.on("connect", () => console.info("Keypad connected"));
124 keypad.on("disconnect", () => console.warn("Keypad lost connection"));
125 if (!dialpad.connected) {
126 console.warn("MX Creative Dialpad is not connected");
127 }
128 dialpad.on("connect", () => console.info("Dialpad connected"));
129 dialpad.on("disconnect", () => console.warn("Dialpad lost connection"));
130
131 const instances = new Map<Mac.BundleId, AppInstance>();
132 const pendingInstances = new Map<Mac.BundleId, Promise<AppInstance | null>>();
133 let currentInstance: AppInstance | null = null;
134 let switchVersion = 0;
135
136 async function getInstance(
137 bundle: Mac.BundleId,
138 ): Promise<AppInstance | null> {
139 const existing = instances.get(bundle);
140 if (existing) return existing;
141
142 const pending = pendingInstances.get(bundle);
143 if (pending) return pending;
144
145 const definition = definitions.find((x) => x.bundle === bundle);
146 if (!definition) return null;
147
148 const next = definition.load().then((config) => {
149 if (config.bundle !== bundle) {
150 throw new Error(
151 `Config bundle mismatch: expected "${bundle}", got "${config.bundle}"`,
152 );
153 }
154
155 const instance: AppInstance = {
156 bundle,
157 keypad: new KeypadSurface(keypad),
158 dialpad: new AppDialpad(dialpad),
159 app: new Events<AppEvents>(),
160 };
161 config.configure({
162 keypad: instance.keypad,
163 dialpad: instance.dialpad,
164 mac,
165 app: instance.app,
166 });
167
168 instances.set(bundle, instance);
169 return instance;
170 }).finally(() => {
171 pendingInstances.delete(bundle);
172 });
173
174 pendingInstances.set(bundle, next);
175 return next;
176 }
177
178 async function switchApp(bundle: Mac.BundleId) {
179 const version = ++switchVersion;
180
181 if (currentInstance) {
182 currentInstance.keypad.setActive(false);
183 currentInstance.dialpad.enabled = false;
184 currentInstance.app.emit("blur");
185 currentInstance = null;
186 }
187
188 const nextInstance = await getInstance(bundle);
189 if (version !== switchVersion) return;
190
191 currentInstance = nextInstance;
192
193 if (currentInstance) {
194 currentInstance.keypad.setActive(true);
195 currentInstance.dialpad.enabled = true;
196 currentInstance.app.emit("focus");
197 } else {
198 keypad.reset();
199 }
200 }
201
202 mac.on("app-change", (id) => {
203 void switchApp(id).catch((error) => {
204 queueMicrotask(() => {
205 throw error;
206 });
207 });
208 });
209 if (mac.currentApp) await switchApp(mac.currentApp);
210}
211
212async function loadConfig(moduleUrl: string): Promise<AppConfig> {
213 const imported = await import(moduleUrl);
214 const config = imported.default as AppConfig | undefined;
215 if (!config) {
216 throw new Error(`Config module ${moduleUrl} has no default export`);
217 }
218 return config;
219}
220
221function readBundleId(source: string): Mac.BundleId | null {
222 const match = source.match(/\bforApp\(\s*(["'`])([^"'`]+)\1/);
223 return (match?.[2] as Mac.BundleId | undefined) ?? null;
224}