@liminal-hq/plugin-gamepad-haptics 0.0.0-stage → 0.1.0

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/api-iife.js ADDED
@@ -0,0 +1,790 @@
1
+ if ('__TAURI__' in window) {
2
+ var __TAURI_PLUGIN_GAMEPAD_HAPTICS__ = (function (exports, core, event) {
3
+ 'use strict';
4
+
5
+ // Matches a pad the game knows (a Web Gamepad, an SDL GUID, a vendor and product) to a pad the plugin can drive
6
+ //
7
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
8
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
9
+ const hex = (s) => Number.parseInt(s, 16);
10
+ /**
11
+ * Reads the vendor and product ids out of a Web Gamepad `id`. Chromium writes
12
+ * `Name (STANDARD GAMEPAD Vendor: 054c Product: 0268)`, Firefox `054c-0268-Name`; Safari writes
13
+ * neither, so it returns undefined and the caller falls back to the name.
14
+ */
15
+ function parseWebGamepadId(id) {
16
+ const chromium = /Vendor:\s*([0-9a-f]{4})\s+Product:\s*([0-9a-f]{4})/i.exec(id);
17
+ if (chromium)
18
+ return { vendorId: hex(chromium[1]), productId: hex(chromium[2]) };
19
+ const firefox = /^([0-9a-f]{4})-([0-9a-f]{4})-/i.exec(id);
20
+ if (firefox)
21
+ return { vendorId: hex(firefox[1]), productId: hex(firefox[2]) };
22
+ return undefined;
23
+ }
24
+ const normaliseName = (s) => s
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, ' ')
27
+ .trim();
28
+ /** The pads that fit `hint`, never guessing between identical ones. */
29
+ function resolvePad(pads, hint) {
30
+ let matches;
31
+ if ('guid' in hint) {
32
+ matches = pads.filter((p) => p.guid.toLowerCase() === hint.guid.toLowerCase());
33
+ }
34
+ else if ('vendorId' in hint) {
35
+ matches = pads.filter((p) => p.vendorId === hint.vendorId && p.productId === hint.productId);
36
+ if (hint.serial !== undefined) {
37
+ const exact = matches.filter((p) => p.serial === hint.serial);
38
+ if (exact.length > 0)
39
+ matches = exact;
40
+ }
41
+ }
42
+ else {
43
+ const ids = parseWebGamepadId(hint.gamepad.id);
44
+ if (ids) {
45
+ matches = pads.filter((p) => p.vendorId === ids.vendorId && p.productId === ids.productId);
46
+ }
47
+ else {
48
+ const name = normaliseName(hint.gamepad.id);
49
+ matches = pads.filter((p) => {
50
+ const n = normaliseName(p.name);
51
+ return n.length > 0 && (name.includes(n) || n.includes(name));
52
+ });
53
+ }
54
+ }
55
+ return { matches, ambiguous: matches.length > 1 };
56
+ }
57
+
58
+ // Portable haptic pattern format, shared by the validator, compiler and scheduler
59
+ //
60
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
61
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
62
+ const PATTERN_FORMAT = 'haptics-lab/pattern@1';
63
+ /** Shortest continuous event, in ms. */
64
+ const MIN_CONTINUOUS_MS = 20;
65
+ /** A transient always plays for at least this long, so it needs that much room before the limit. */
66
+ const MIN_TRANSIENT_MS = 1;
67
+
68
+ // Compiles a haptic pattern into motor frames for a pad's tier
69
+ //
70
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
71
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
72
+ // Pure TypeScript, like the rest of this folder. The Rust rules in `src/validate.rs` are the source
73
+ // of truth for what a frame list may hold; this compiler emits inside them.
74
+ const MAX_FRAMES = 512;
75
+ /** Time between samples. Longer patterns widen it so they stay within `MAX_FRAMES`. */
76
+ const SAMPLE_MS = 10;
77
+ /** A transient is stretched to this long, so a motor has time to spin up. */
78
+ const MIN_TAP_MS = 40;
79
+ /** Levels that differ by less than this are the same frame. */
80
+ const SAME_LEVEL = 0.02;
81
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
82
+ /** The value of a constant or a `{t, v}` curve at `t`, 0..1 across the event. */
83
+ function curveAt(curve, t) {
84
+ if (typeof curve === 'number')
85
+ return clamp01(curve);
86
+ if (curve.length === 0)
87
+ return 0;
88
+ const points = [...curve].sort((a, b) => a.t - b.t);
89
+ if (t <= points[0].t)
90
+ return clamp01(points[0].v);
91
+ for (let i = 1; i < points.length; i++) {
92
+ const a = points[i - 1];
93
+ const b = points[i];
94
+ if (t <= b.t) {
95
+ const span = b.t - a.t;
96
+ return clamp01(span === 0 ? b.v : a.v + ((b.v - a.v) * (t - a.t)) / span);
97
+ }
98
+ }
99
+ return clamp01(points[points.length - 1].v);
100
+ }
101
+ /**
102
+ * How an intensity and a sharpness split across the two body motors. Sharp is crisp and goes to the
103
+ * light motor, dull is deep and goes to the heavy motor; the 1.25 keeps a mid sharpness from
104
+ * halving both.
105
+ */
106
+ function mix(intensity, sharpness) {
107
+ const i = clamp01(intensity);
108
+ const s = clamp01(sharpness);
109
+ return {
110
+ heavy: i * Math.min(1, 1.25 * (1 - s)),
111
+ light: i * Math.min(1, 1.25 * s),
112
+ };
113
+ }
114
+ function endOf(event) {
115
+ return event.type === 'transient'
116
+ ? event.at + MIN_TAP_MS
117
+ : event.at + Math.max(event.duration, MIN_CONTINUOUS_MS);
118
+ }
119
+ /** The levels every event asks for at time `t`; overlapping events take the stronger level. */
120
+ function levelsAt(events, t, tier) {
121
+ const out = { heavy: 0, light: 0, left: 0, right: 0 };
122
+ for (const event of events) {
123
+ if (t < event.at || t >= endOf(event))
124
+ continue;
125
+ let m;
126
+ let trigger;
127
+ if (event.type === 'transient') {
128
+ m = mix(event.intensity, event.sharpness);
129
+ trigger = { left: 0, right: clamp01(event.intensity) };
130
+ }
131
+ else {
132
+ const c = event;
133
+ const p = (t - c.at) / Math.max(c.duration, MIN_CONTINUOUS_MS);
134
+ const i = curveAt(c.intensity, p);
135
+ m = mix(i, curveAt(c.sharpness, p));
136
+ trigger = { left: i, right: i };
137
+ }
138
+ out.heavy = Math.max(out.heavy, m.heavy);
139
+ out.light = Math.max(out.light, m.light);
140
+ if (tier >= 3) {
141
+ out.left = Math.max(out.left, trigger.left);
142
+ out.right = Math.max(out.right, trigger.right);
143
+ }
144
+ }
145
+ if (tier <= 1) {
146
+ out.heavy = Math.max(out.heavy, out.light);
147
+ out.light = 0;
148
+ }
149
+ return out;
150
+ }
151
+ const near = (a, b) => Math.abs(a.heavy - b.heavy) < SAME_LEVEL &&
152
+ Math.abs(a.light - b.light) < SAME_LEVEL &&
153
+ Math.abs((a.leftTrigger ?? 0) - (b.leftTrigger ?? 0)) < SAME_LEVEL &&
154
+ Math.abs((a.rightTrigger ?? 0) - (b.rightTrigger ?? 0)) < SAME_LEVEL;
155
+ const isSilent = (f) => f.heavy === 0 && f.light === 0 && !(f.leftTrigger ?? 0) && !(f.rightTrigger ?? 0);
156
+ /**
157
+ * The tier a frame list needs, which is what the Rust side reports back as its request tier: 3 with
158
+ * triggers, 2 when both body motors are used and differ somewhere, else 1.
159
+ */
160
+ function framesTier(frames) {
161
+ if (frames.length === 0 || frames.every(isSilent))
162
+ return 0;
163
+ if (frames.some((f) => (f.leftTrigger ?? 0) > 0 || (f.rightTrigger ?? 0) > 0))
164
+ return 3;
165
+ const heavy = frames.some((f) => f.heavy > 0);
166
+ const light = frames.some((f) => f.light > 0);
167
+ if (heavy && light && frames.some((f) => f.heavy !== f.light))
168
+ return 2;
169
+ return 1;
170
+ }
171
+ const round = (v) => Math.round(v * 1000) / 1000;
172
+ function compilePad(pattern, caps, options = {}) {
173
+ const notes = [];
174
+ const tier = Math.min(caps.topTier, options.maxTier ?? 3);
175
+ if (tier === 0) {
176
+ return { frames: [], tier: 0, estimatedMs: 0, notes: ['This pad cannot play rumble'] };
177
+ }
178
+ if (tier < 3 && caps.topTier >= 3 && options.maxTier !== undefined) {
179
+ notes.push(`Compiled for tier ${tier} as asked`);
180
+ }
181
+ const events = [...pattern.events].sort((a, b) => a.at - b.at);
182
+ let end = Math.max(0, ...events.map(endOf));
183
+ if (end > caps.maxDurationMs) {
184
+ end = caps.maxDurationMs;
185
+ notes.push(`Truncated at the limit of ${caps.maxDurationMs} ms`);
186
+ }
187
+ if (end === 0)
188
+ return { frames: [], tier: 0, estimatedMs: 0, notes };
189
+ // Widen the sample step when a long pattern would otherwise need more frames than allowed.
190
+ const step = Math.max(SAMPLE_MS, Math.ceil(end / (MAX_FRAMES - 1)));
191
+ if (step > SAMPLE_MS)
192
+ notes.push(`Sampled every ${step} ms to stay within ${MAX_FRAMES} frames`);
193
+ const frames = [];
194
+ for (let start = 0; start < end; start += step) {
195
+ const durationMs = Math.min(step, end - start);
196
+ const l = levelsAt(events, start + durationMs / 2, tier);
197
+ const frame = { durationMs, heavy: round(l.heavy), light: round(l.light) };
198
+ if (tier >= 3) {
199
+ frame.leftTrigger = round(l.left);
200
+ frame.rightTrigger = round(l.right);
201
+ }
202
+ const last = frames[frames.length - 1];
203
+ if (last && near(last, frame))
204
+ last.durationMs += durationMs;
205
+ else
206
+ frames.push(frame);
207
+ }
208
+ // A trailing silence carries no rumble.
209
+ while (frames.length > 0 && isSilent(frames[frames.length - 1]))
210
+ frames.pop();
211
+ const estimatedMs = frames.reduce((sum, f) => sum + f.durationMs, 0);
212
+ const needed = framesTier(frames);
213
+ if (needed === 0) {
214
+ notes.push('Nothing in the pattern is strong enough to feel');
215
+ return { frames: [], tier: 0, estimatedMs: 0, notes };
216
+ }
217
+ return { frames, tier: Math.min(needed, tier), estimatedMs, notes };
218
+ }
219
+
220
+ // Per-pattern scheduler for the interrupt, queue, drop-if-busy and coalesce policies
221
+ //
222
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
223
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
224
+ const MAX_QUEUE = 4;
225
+ const MAX_MERGES = 3;
226
+ const MERGE_BOOST = 0.15;
227
+ /**
228
+ * Decides whether and when a compiled pattern runs. It only schedules: playing is the caller's
229
+ * `run` function, so the same scheduler serves any backend. `stop()` clears every queue and timer.
230
+ */
231
+ class PatternScheduler {
232
+ state = new Map();
233
+ submit(job) {
234
+ const st = this.get(job.key);
235
+ const policy = job.policy;
236
+ const now = Date.now();
237
+ if (policy === 'interrupt') {
238
+ this.clear(st);
239
+ return this.play(st, job, job.scale, 'played');
240
+ }
241
+ if (policy === 'drop-if-busy') {
242
+ if (now < st.busyUntil)
243
+ return Promise.resolve({ policy: 'dropped' });
244
+ return this.play(st, job, job.scale, 'played');
245
+ }
246
+ if (policy === 'queue') {
247
+ if (now >= st.busyUntil && st.queue.length === 0) {
248
+ return this.play(st, job, job.scale, 'played');
249
+ }
250
+ if (st.queue.length >= MAX_QUEUE)
251
+ return Promise.resolve({ policy: 'dropped' });
252
+ return this.enqueue(st, job);
253
+ }
254
+ return this.coalesce(st, job, policy.coalesce);
255
+ }
256
+ /** True while the pattern with this key is still within its estimated run time. */
257
+ isBusy(key) {
258
+ const st = this.state.get(key);
259
+ return st !== undefined && Date.now() < st.busyUntil;
260
+ }
261
+ /** Cancels one pattern's queued, merged and pending triggers and forgets its state. */
262
+ cancel(key) {
263
+ const st = this.state.get(key);
264
+ if (!st)
265
+ return;
266
+ this.clear(st);
267
+ this.state.delete(key);
268
+ }
269
+ /** Cancels every queued, merged and pending trigger and clears the busy state. */
270
+ stop() {
271
+ for (const st of this.state.values())
272
+ this.clear(st);
273
+ this.state.clear();
274
+ }
275
+ get(key) {
276
+ let st = this.state.get(key);
277
+ if (!st) {
278
+ st = { busyUntil: 0, queue: [] };
279
+ this.state.set(key, st);
280
+ }
281
+ return st;
282
+ }
283
+ clear(st) {
284
+ for (const w of st.queue) {
285
+ clearTimeout(w.timer);
286
+ w.settle({ policy: 'dropped' });
287
+ }
288
+ st.queue = [];
289
+ if (st.group) {
290
+ clearTimeout(st.group.timer);
291
+ st.group.settle({ policy: 'dropped' });
292
+ st.group = undefined;
293
+ }
294
+ st.busyUntil = 0;
295
+ }
296
+ /** A run that played nothing was never busy, so the pattern is free again. */
297
+ release(st, job, result) {
298
+ if (job.didPlay && !job.didPlay(result) && st.queue.length === 0)
299
+ st.busyUntil = 0;
300
+ }
301
+ async play(st, job, scale, policy) {
302
+ st.busyUntil = Date.now() + job.estimatedMs;
303
+ try {
304
+ const result = await job.run(Math.min(1, scale));
305
+ this.release(st, job, result);
306
+ return { policy, result };
307
+ }
308
+ catch (err) {
309
+ // A play that failed never ran, so the pattern is not busy.
310
+ st.busyUntil = 0;
311
+ throw err;
312
+ }
313
+ }
314
+ enqueue(st, job) {
315
+ const start = Math.max(Date.now(), st.busyUntil);
316
+ const wait = start - Date.now();
317
+ // Reserve the slot now so a later trigger queues behind this one.
318
+ st.busyUntil = start + job.estimatedMs;
319
+ return new Promise((resolve, reject) => {
320
+ const entry = {
321
+ settle: resolve,
322
+ timer: setTimeout(() => {
323
+ st.queue = st.queue.filter((w) => w !== entry);
324
+ // Start from a promise so a synchronous throw in `run` rejects instead of escaping.
325
+ Promise.resolve()
326
+ .then(() => job.run(job.scale))
327
+ .then((result) => {
328
+ this.release(st, job, result);
329
+ resolve({ policy: 'queued', result });
330
+ }, reject);
331
+ }, wait),
332
+ };
333
+ st.queue.push(entry);
334
+ });
335
+ }
336
+ coalesce(st, job, windowMs) {
337
+ const group = st.group;
338
+ if (group) {
339
+ // Merge into the open group; once it holds three merges later triggers are absorbed
340
+ // without a further boost.
341
+ if (group.merges < MAX_MERGES)
342
+ group.merges++;
343
+ group.scale = Math.max(group.scale, job.scale);
344
+ return Promise.resolve({ policy: 'coalesced' });
345
+ }
346
+ return new Promise((resolve, reject) => {
347
+ const created = {
348
+ job,
349
+ merges: 0,
350
+ scale: job.scale,
351
+ settle: resolve,
352
+ timer: setTimeout(() => {
353
+ st.group = undefined;
354
+ const boosted = created.scale + MERGE_BOOST * created.merges;
355
+ this.play(st, job, boosted, 'played').then(resolve, reject);
356
+ }, windowMs),
357
+ };
358
+ st.group = created;
359
+ });
360
+ }
361
+ }
362
+
363
+ // Pattern validation that reports every problem at once, each with a path and a fix
364
+ //
365
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
366
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
367
+ const USAGES = ['touch', 'notification', 'alarm', 'media'];
368
+ const POLICIES = ['interrupt', 'queue', 'drop-if-busy'];
369
+ /** Longest coalesce window; far above this a timer would be a bug, not a window. */
370
+ const MAX_COALESCE_MS = 1000;
371
+ function isRecord(v) {
372
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
373
+ }
374
+ function isNumber(v) {
375
+ return typeof v === 'number' && Number.isFinite(v);
376
+ }
377
+ function show(v) {
378
+ return typeof v === 'number' || typeof v === 'string' ? String(v) : typeof v;
379
+ }
380
+ /** Checks a number that must sit in 0..1. */
381
+ function checkUnit(value, path, issues) {
382
+ if (!isNumber(value)) {
383
+ issues.push({ path, message: `${show(value)} is not a number. Use 0..1.` });
384
+ }
385
+ else if (value > 1) {
386
+ issues.push({ path, message: `${value} is above 1. Use 0..1.` });
387
+ }
388
+ else if (value < 0) {
389
+ issues.push({ path, message: `${value} is below 0. Use 0..1.` });
390
+ }
391
+ }
392
+ function checkCurve(points, path, issues) {
393
+ if (points.length < 2) {
394
+ issues.push({
395
+ path,
396
+ message: `${points.length} point${points.length === 1 ? '' : 's'}. A curve needs at least 2.`,
397
+ });
398
+ return;
399
+ }
400
+ let previous = -Infinity;
401
+ points.forEach((point, i) => {
402
+ const here = `${path}[${i}]`;
403
+ if (!isRecord(point)) {
404
+ issues.push({ path: here, message: 'Not a point. Use { t, v } with both in 0..1.' });
405
+ return;
406
+ }
407
+ checkUnit(point.v, `${here}.v`, issues);
408
+ if (!isNumber(point.t)) {
409
+ issues.push({ path: `${here}.t`, message: `${show(point.t)} is not a number. Use 0..1.` });
410
+ return;
411
+ }
412
+ if (point.t < 0 || point.t > 1) {
413
+ issues.push({ path: `${here}.t`, message: `${point.t} is outside 0..1. Use 0..1.` });
414
+ }
415
+ if (point.t <= previous) {
416
+ issues.push({
417
+ path: `${here}.t`,
418
+ message: `${point.t} does not come after ${previous}. Keep t ascending.`,
419
+ });
420
+ }
421
+ previous = point.t;
422
+ });
423
+ const first = points[0];
424
+ const last = points[points.length - 1];
425
+ if (isRecord(first) && isNumber(first.t) && first.t !== 0) {
426
+ issues.push({ path: `${path}[0].t`, message: `${first.t} is not 0. A curve starts at t = 0.` });
427
+ }
428
+ if (isRecord(last) && isNumber(last.t) && last.t !== 1) {
429
+ issues.push({
430
+ path: `${path}[${points.length - 1}].t`,
431
+ message: `${last.t} is not 1. A curve ends at t = 1.`,
432
+ });
433
+ }
434
+ }
435
+ function checkLevel(value, path, issues) {
436
+ if (Array.isArray(value))
437
+ checkCurve(value, path, issues);
438
+ else
439
+ checkUnit(value, path, issues);
440
+ }
441
+ /**
442
+ * Returns every problem with `input`, or an empty list when it is a valid pattern. Never throws.
443
+ */
444
+ function validatePattern(input, opts = {}) {
445
+ const issues = [];
446
+ if (!isRecord(input)) {
447
+ return [{ path: '', message: 'Not a pattern. Use an object with a format and events.' }];
448
+ }
449
+ if (input.format !== PATTERN_FORMAT) {
450
+ issues.push({
451
+ path: 'format',
452
+ message: `${show(input.format)} is not a known format. Use "${PATTERN_FORMAT}".`,
453
+ });
454
+ }
455
+ if (input.id !== undefined && (typeof input.id !== 'string' || input.id.length === 0)) {
456
+ issues.push({ path: 'id', message: 'The id must be a non-empty string.' });
457
+ }
458
+ if (input.usage !== undefined && !USAGES.includes(input.usage)) {
459
+ issues.push({
460
+ path: 'usage',
461
+ message: `${show(input.usage)} is not a usage. Use ${USAGES.join(', ')}.`,
462
+ });
463
+ }
464
+ checkPolicy(input.policy, issues);
465
+ const events = input.events;
466
+ if (!Array.isArray(events)) {
467
+ issues.push({ path: 'events', message: 'Events must be a list.' });
468
+ return issues;
469
+ }
470
+ if (events.length === 0) {
471
+ issues.push({ path: 'events', message: 'No events. Add at least one.' });
472
+ return issues;
473
+ }
474
+ let end = 0;
475
+ events.forEach((event, i) => {
476
+ const at = `events[${i}]`;
477
+ if (!isRecord(event)) {
478
+ issues.push({ path: at, message: 'Not an event. Use a transient or a continuous event.' });
479
+ return;
480
+ }
481
+ if (!isNumber(event.at)) {
482
+ issues.push({
483
+ path: `${at}.at`,
484
+ message: `${show(event.at)} is not a number. Use ms from 0.`,
485
+ });
486
+ }
487
+ else if (event.at < 0) {
488
+ issues.push({
489
+ path: `${at}.at`,
490
+ message: `${event.at} ms is before the start. Use 0 or more.`,
491
+ });
492
+ }
493
+ const start = isNumber(event.at) && event.at >= 0 ? event.at : 0;
494
+ if (event.type === 'transient') {
495
+ checkUnit(event.intensity, `${at}.intensity`, issues);
496
+ checkUnit(event.sharpness, `${at}.sharpness`, issues);
497
+ end = Math.max(end, start + MIN_TRANSIENT_MS);
498
+ }
499
+ else if (event.type === 'continuous') {
500
+ if (!isNumber(event.duration)) {
501
+ issues.push({
502
+ path: `${at}.duration`,
503
+ message: `${show(event.duration)} is not a number. Use ms, at least ${MIN_CONTINUOUS_MS}.`,
504
+ });
505
+ }
506
+ else if (event.duration < MIN_CONTINUOUS_MS) {
507
+ issues.push({
508
+ path: `${at}.duration`,
509
+ message: `${event.duration} ms. Continuous events need at least ${MIN_CONTINUOUS_MS} ms.`,
510
+ });
511
+ }
512
+ else {
513
+ end = Math.max(end, start + event.duration);
514
+ }
515
+ checkLevel(event.intensity, `${at}.intensity`, issues);
516
+ checkLevel(event.sharpness, `${at}.sharpness`, issues);
517
+ }
518
+ else {
519
+ issues.push({
520
+ path: `${at}.type`,
521
+ message: `${show(event.type)} is not an event type. Use "transient" or "continuous".`,
522
+ });
523
+ }
524
+ });
525
+ if (opts.maxDurationMs !== undefined && end > opts.maxDurationMs) {
526
+ issues.push({
527
+ path: 'events',
528
+ message: `The pattern runs for ${end} ms. The limit is ${opts.maxDurationMs} ms; shorten or move events earlier.`,
529
+ });
530
+ }
531
+ return issues;
532
+ }
533
+ function checkPolicy(policy, issues) {
534
+ if (policy === undefined)
535
+ return;
536
+ if (typeof policy === 'string') {
537
+ if (!POLICIES.includes(policy)) {
538
+ issues.push({
539
+ path: 'policy',
540
+ message: `${policy} is not a policy. Use ${POLICIES.join(', ')} or { coalesce: ms }.`,
541
+ });
542
+ }
543
+ return;
544
+ }
545
+ if (isRecord(policy) &&
546
+ isNumber(policy.coalesce) &&
547
+ policy.coalesce > 0 &&
548
+ policy.coalesce <= MAX_COALESCE_MS) {
549
+ return;
550
+ }
551
+ issues.push({
552
+ path: 'policy',
553
+ message: `Not a policy. Use interrupt, queue, drop-if-busy or { coalesce: ms } with ms from 1 to ${MAX_COALESCE_MS}.`,
554
+ });
555
+ }
556
+ /** One line per issue, for error messages and logs. */
557
+ function formatIssues(issues) {
558
+ return issues.map((i) => (i.path ? `${i.path}: ${i.message}` : i.message)).join('\n');
559
+ }
560
+
561
+ // Plays frames on a pad through the webview's Gamepad API, for when no native path can address it
562
+ //
563
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
564
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
565
+ /** The pads the webview exposes. The browser lists none until a button is pressed on the page. */
566
+ function webPads() {
567
+ if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function')
568
+ return [];
569
+ return Array.from(navigator.getGamepads()).filter((p) => p !== null && p.connected !== false);
570
+ }
571
+ /** Whether a web pad can rumble: it needs a `dual-rumble` actuator. */
572
+ function canRumble(pad) {
573
+ return typeof pad.vibrationActuator?.playEffect === 'function';
574
+ }
575
+ const running = new Map();
576
+ /** Stops one web pad, or every pad when `index` is omitted. */
577
+ async function stopWeb(index) {
578
+ const targets = index === undefined ? [...running.keys()] : [index];
579
+ for (const i of targets) {
580
+ for (const timer of running.get(i) ?? [])
581
+ clearTimeout(timer);
582
+ running.delete(i);
583
+ const pad = webPads().find((p) => p.index === i);
584
+ await pad?.vibrationActuator?.reset?.().catch(() => undefined);
585
+ }
586
+ }
587
+ /**
588
+ * Plays `frames` on a web pad: one `dual-rumble` effect per frame, each started on its own timer
589
+ * so a pad that drops one still ends silent. Returns the time the pattern takes.
590
+ */
591
+ async function playWeb(pad, frames, scale = 1) {
592
+ const actuator = pad.vibrationActuator;
593
+ if (!actuator?.playEffect)
594
+ throw new Error('This pad has no vibration actuator');
595
+ await stopWeb(pad.index);
596
+ const timers = [];
597
+ running.set(pad.index, timers);
598
+ let at = 0;
599
+ for (const frame of frames) {
600
+ const params = {
601
+ startDelay: 0,
602
+ duration: frame.durationMs,
603
+ strongMagnitude: Math.min(1, frame.heavy * scale),
604
+ weakMagnitude: Math.min(1, frame.light * scale),
605
+ };
606
+ if (at === 0) {
607
+ await actuator.playEffect('dual-rumble', params);
608
+ }
609
+ else {
610
+ timers.push(setTimeout(() => void actuator.playEffect?.('dual-rumble', params), at));
611
+ }
612
+ at += frame.durationMs;
613
+ }
614
+ timers.push(setTimeout(() => void stopWeb(pad.index), at + 50));
615
+ return at;
616
+ }
617
+
618
+ // Typed guest-side wrappers for the gamepad-haptics plugin
619
+ //
620
+ // (c) Copyright 2026 Liminal HQ, Scott Morris
621
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
622
+ const PREFIX = 'plugin:gamepad-haptics|';
623
+ const PAD_CONNECTED_EVENT = 'gamepad-haptics://connected';
624
+ const PAD_CHANGED_EVENT = 'gamepad-haptics://changed';
625
+ const PAD_DISCONNECTED_EVENT = 'gamepad-haptics://disconnected';
626
+ function capabilities() {
627
+ return core.invoke(`${PREFIX}capabilities`);
628
+ }
629
+ function listPads() {
630
+ return core.invoke(`${PREFIX}list_pads`);
631
+ }
632
+ /**
633
+ * Plays motor frames on one native pad. Rust validates them first, so a bad request is rejected
634
+ * even when `scale` is 0 or the pad cannot play. `scale` multiplies with the configured master scale.
635
+ */
636
+ function playFrames(padId, frames, scale) {
637
+ return core.invoke(`${PREFIX}play_frames`, { args: { padId, frames, scale } });
638
+ }
639
+ /** Buzzes one pad in a pattern that tells it from the others, so a player can confirm which it is. */
640
+ function identify(padId) {
641
+ return core.invoke(`${PREFIX}identify`, { padId });
642
+ }
643
+ /** Stops one pad, or every pad when `padId` is omitted. */
644
+ async function stop(padId) {
645
+ await core.invoke(`${PREFIX}stop`, { padId });
646
+ if (padId === undefined)
647
+ await stopWeb();
648
+ }
649
+ function onPadConnected(handler) {
650
+ return event.listen(PAD_CONNECTED_EVENT, (e) => handler(e.payload));
651
+ }
652
+ function onPadChanged(handler) {
653
+ return event.listen(PAD_CHANGED_EVENT, (e) => handler(e.payload));
654
+ }
655
+ function onPadDisconnected(handler) {
656
+ return event.listen(PAD_DISCONNECTED_EVENT, (e) => handler(e.payload));
657
+ }
658
+ const silent = (target, reason) => ({
659
+ ok: true,
660
+ tier: 0,
661
+ target,
662
+ downgraded: true,
663
+ reason,
664
+ });
665
+ /**
666
+ * Creates a backend that plays on a native pad when the plugin can address one and falls back to the
667
+ * webview's Gamepad API when it cannot. One pad is never driven by both: a pad found natively is
668
+ * played natively. Do not call `playEffect` on the same pad yourself while this backend plays.
669
+ */
670
+ function createBackend() {
671
+ const patterns = new Map();
672
+ const scheduler = new PatternScheduler();
673
+ let masterScale = 1;
674
+ let maxTier = null;
675
+ async function limitsAndPads() {
676
+ return capabilities();
677
+ }
678
+ function compileFor(pattern, topTier, maxDurationMs) {
679
+ return compilePad(pattern, { topTier, maxDurationMs }, maxTier === null ? {} : { maxTier });
680
+ }
681
+ return {
682
+ id: 'gamepad',
683
+ capabilities: limitsAndPads,
684
+ async register(id, pattern) {
685
+ const caps = await limitsAndPads();
686
+ const issues = validatePattern(pattern, { maxDurationMs: caps.limits.maxDurationMs });
687
+ if (issues.length > 0)
688
+ throw new Error(formatIssues(issues));
689
+ patterns.set(id, pattern);
690
+ const best = caps.pads.reduce((t, p) => Math.max(t, p.topTier), 0);
691
+ return compileFor(pattern, best === 0 ? 2 : best, caps.limits.maxDurationMs);
692
+ },
693
+ async trigger(id, options = {}) {
694
+ const pattern = patterns.get(id);
695
+ if (!pattern)
696
+ throw new Error(`Unknown pattern \`${id}\`. Register it first.`);
697
+ const caps = await limitsAndPads();
698
+ // Rust rejects a scale over 1, so the product is held to 0..1 however the caller scaled it.
699
+ const scale = Math.min(1, Math.max(0, (options.scale ?? 1) * masterScale));
700
+ const policy = pattern.policy ?? 'interrupt';
701
+ // Choose the pad: an id, a hint, or the first native pad that can play.
702
+ let native;
703
+ let hintedWeb;
704
+ if (options.padId !== undefined) {
705
+ native = caps.pads.find((p) => p.id === options.padId);
706
+ if (!native)
707
+ return silent(options.padId, `No pad \`${options.padId}\``);
708
+ }
709
+ else if (options.hint !== undefined) {
710
+ const found = resolvePad(caps.pads, options.hint);
711
+ if (found.ambiguous) {
712
+ return silent('gamepad', 'More than one pad fits; call identify() and pass the padId the player confirms');
713
+ }
714
+ native = found.matches[0];
715
+ if (!native && 'gamepad' in options.hint)
716
+ hintedWeb = options.hint.gamepad.index;
717
+ }
718
+ else {
719
+ native = caps.pads.find((p) => p.topTier > 0);
720
+ }
721
+ if (native) {
722
+ const report = compileFor(pattern, native.topTier, caps.limits.maxDurationMs);
723
+ if (report.frames.length === 0) {
724
+ return silent(native.id, report.notes[0] ?? 'Nothing to play');
725
+ }
726
+ const padId = native.id;
727
+ const outcome = await scheduler.submit({
728
+ key: id,
729
+ policy,
730
+ estimatedMs: report.estimatedMs,
731
+ scale,
732
+ run: (s) => playFrames(padId, report.frames, s === 1 ? undefined : s),
733
+ didPlay: (res) => res.tier > 0,
734
+ });
735
+ return outcome.result ?? silent(padId, `Not played: ${outcome.policy}`);
736
+ }
737
+ // No native pad: use the webview's Gamepad API.
738
+ const web = webPads().filter(canRumble);
739
+ const pad = hintedWeb === undefined ? web[0] : web.find((p) => p.index === hintedWeb);
740
+ if (!pad)
741
+ return silent('gamepad', 'No gamepad found');
742
+ const report = compileFor(pattern, 2, caps.limits.maxDurationMs);
743
+ if (report.frames.length === 0)
744
+ return silent(`web:${pad.index}`, report.notes[0] ?? 'Nothing to play');
745
+ const outcome = await scheduler.submit({
746
+ key: id,
747
+ policy,
748
+ estimatedMs: report.estimatedMs,
749
+ scale,
750
+ run: async (s) => {
751
+ await playWeb(pad, report.frames, s);
752
+ return { ok: true, tier: report.tier, target: `web:${pad.index}`, downgraded: false };
753
+ },
754
+ didPlay: (res) => res.tier > 0,
755
+ });
756
+ return outcome.result ?? silent(`web:${pad.index}`, `Not played: ${outcome.policy}`);
757
+ },
758
+ setMasterScale(value) {
759
+ masterScale = Math.min(1, Math.max(0, value));
760
+ },
761
+ setMaxTier(tier) {
762
+ maxTier = tier;
763
+ },
764
+ async stop() {
765
+ scheduler.stop();
766
+ await stop();
767
+ },
768
+ };
769
+ }
770
+
771
+ exports.PAD_CHANGED_EVENT = PAD_CHANGED_EVENT;
772
+ exports.PAD_CONNECTED_EVENT = PAD_CONNECTED_EVENT;
773
+ exports.PAD_DISCONNECTED_EVENT = PAD_DISCONNECTED_EVENT;
774
+ exports.PATTERN_FORMAT = PATTERN_FORMAT;
775
+ exports.capabilities = capabilities;
776
+ exports.createBackend = createBackend;
777
+ exports.identify = identify;
778
+ exports.listPads = listPads;
779
+ exports.onPadChanged = onPadChanged;
780
+ exports.onPadConnected = onPadConnected;
781
+ exports.onPadDisconnected = onPadDisconnected;
782
+ exports.parseWebGamepadId = parseWebGamepadId;
783
+ exports.playFrames = playFrames;
784
+ exports.resolvePad = resolvePad;
785
+ exports.stop = stop;
786
+
787
+ return exports;
788
+
789
+ })({}, __TAURI__.core, __TAURI__.event);
790
+ Object.defineProperty(window.__TAURI__, 'gamepadHaptics', { value: __TAURI_PLUGIN_GAMEPAD_HAPTICS__ }) }