@kehto/services 0.21.1 → 0.21.2

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/README.md CHANGED
@@ -251,6 +251,8 @@ export interface HostKeysBridge {
251
251
  * - invoke `callback` exactly once per matching chord event (implementations
252
252
  * are responsible for any OS-autorepeat filtering)
253
253
  * - invoke `callback` synchronously during the event delivery
254
+ * - remove only this subscription when its unsubscribe handle is called;
255
+ * other callbacks for the same chord must remain live
254
256
  * - accept the string chord format documented by @napplet/nap/keys
255
257
  * (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`)
256
258
  */
@@ -300,16 +302,33 @@ keys.destroy();
300
302
  Custom bridge path — swap in Electron's `globalShortcut`:
301
303
 
302
304
  ```ts
303
- import { createKeysService, type HostKeysBridge } from '@kehto/services';
305
+ import { createKeysService, type HostKeysBridge, type HostKeyEvent } from '@kehto/services';
304
306
  import { globalShortcut } from 'electron';
305
307
 
308
+ const subscribers = new Map<string, Set<(event: HostKeyEvent) => void>>();
306
309
  const electronBridge: HostKeysBridge = {
307
310
  subscribe(chord, cb) {
308
- globalShortcut.register(chord, () => cb({
309
- key: '', code: '',
310
- ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
311
- } as KeyboardEvent));
312
- return () => globalShortcut.unregister(chord);
311
+ let callbacks = subscribers.get(chord);
312
+ if (!callbacks) {
313
+ const group = new Set<(event: HostKeyEvent) => void>();
314
+ const registered = globalShortcut.register(chord, () => {
315
+ for (const callback of group) callback({
316
+ key: '', code: '',
317
+ ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
318
+ });
319
+ });
320
+ if (!registered) throw new Error(`Cannot register ${chord}`);
321
+ callbacks = group;
322
+ subscribers.set(chord, callbacks);
323
+ }
324
+ callbacks.add(cb);
325
+ return () => {
326
+ if (!callbacks.delete(cb)) return;
327
+ if (callbacks.size === 0) {
328
+ globalShortcut.unregister(chord);
329
+ subscribers.delete(chord);
330
+ }
331
+ };
313
332
  },
314
333
  };
315
334
 
@@ -322,6 +341,23 @@ Plug a `HostKeysBridge` when the default document listener is insufficient: Elec
322
341
 
323
342
  A napplet drives this end to end via `@napplet/sdk` — `keys.registerAction` to claim a chord and `keys.onAction` to receive dispatches against the real backend. After a successful bound registration, the service pushes a complete `keys.bindings` list for that napplet/window, using entries shaped as `{ actionId, key }`. It pushes the complete list again after `keys.unregisterAction` removes a binding, including an empty list when no bindings remain. Injected shims use that list to suppress locally-bound keydowns before forwarding.
324
343
 
344
+ Action IDs are local to the trusted window passed by the runtime. Two windows may
345
+ each register `game.a`, including with the same suggested key, without replacing
346
+ one another's bindings. Unregistering or destroying one window removes only its
347
+ actions and bridge subscriptions. Binding lists and `keys.action` messages retain
348
+ the original app-local action ID; callers do not need to invent window-specific IDs.
349
+ Re-registering an action in the same window retains the existing rebind behavior:
350
+ a valid available chord replaces that window's binding, a reserved chord removes
351
+ its previous binding, and a malformed chord or failed bridge subscription leaves
352
+ the previous binding intact.
353
+
354
+ This ownership scope is an explicit Kehto policy for the scope left implicit by
355
+ [NAP-KEYS PR #9 at `cecb642`](https://github.com/napplet/naps/blob/cecb64257e0ac29926bb746832a477c553ab307c/naps/NAP-KEYS.md).
356
+ The draft describes duplicate-action errors but does not mandate service-global
357
+ IDs or forbid same-owner updates. Window-scoped ownership preserves its per-napplet
358
+ binding delivery and adds no wire fields. Chord conflicts retain the existing
359
+ behavior: matching registered actions receive their own `keys.action` push.
360
+
325
361
  ### Reserved Chords
326
362
 
327
363
  Shell-reserved chords let a host application (window manager, launcher shell, tiling WM) claim specific chords for its own dispatch regardless of what napplets subscribe to. Declare the reserved set once at service construction via the `reservedChords` option on [`KeysServiceOptions`](#keysserviceoptions):
package/dist/index.d.ts CHANGED
@@ -507,7 +507,7 @@ declare function createCoordinatedRelay(options: CoordinatedRelayOptions): Servi
507
507
  * - keys.forward -> invokes options.onForward (hotkey passthrough, fire-and-forget)
508
508
  * - keys.registerAction -> parses action.defaultKey into a normalized chord binding,
509
509
  * stores bound subscriptions in an in-memory registry keyed
510
- * by actionId, tracks windowId ownership so onWindowDestroyed
510
+ * by windowId + actionId so onWindowDestroyed
511
511
  * can auto-unsubscribe, echoes { actionId, binding } as .result
512
512
  * when a binding is assigned, and pushes the window's complete
513
513
  * keys.bindings list
@@ -577,17 +577,8 @@ interface HostKeyEvent {
577
577
  *
578
578
  * @example
579
579
  * ```ts
580
- * // Host-app pseudocode (Electron main-process relay):
581
- * const electronBridge: HostKeysBridge = {
582
- * subscribe(chord, cb) {
583
- * const handle = globalShortcut.register(chord, () => cb({ key: '', code: '', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false }));
584
- * return () => globalShortcut.unregister(chord);
585
- * },
586
- * registerGlobalHotkey: (chord) => globalShortcut.register(chord, () => {}),
587
- * onGlobalHotkey: (cb) => globalHotkeyBridge.on('global-hotkey', (_, chord) => cb(chord)),
588
- * };
589
- *
590
- * const keys = createKeysService({ hostBridge: electronBridge });
580
+ * declare const hostBridge: HostKeysBridge;
581
+ * const keys = createKeysService({ hostBridge });
591
582
  * runtime.registerService('keys', keys);
592
583
  * ```
593
584
  */
@@ -599,6 +590,8 @@ interface HostKeysBridge {
599
590
  * - invoke `callback` exactly once per matching chord event (implementations
600
591
  * are responsible for any OS-autorepeat filtering)
601
592
  * - invoke `callback` synchronously during the event delivery
593
+ * - remove only this subscription when its unsubscribe handle is called;
594
+ * other callbacks for the same chord must remain live
602
595
  * - accept the string chord format documented by @napplet/nap/keys
603
596
  * (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`)
604
597
  */
@@ -665,7 +658,7 @@ interface KeysServiceOptions {
665
658
  /**
666
659
  * Optional pluggable backend for chord subscription. When provided, the
667
660
  * service delegates `keys.registerAction` → `bridge.subscribe(chord, cb)`
668
- * and stores the returned unsubscribe handle keyed on `actionId`. The
661
+ * and stores the returned unsubscribe handle keyed on windowId + actionId. The
669
662
  * default document-listener path is NOT attached when `hostBridge` is
670
663
  * provided — the bridge is authoritative. See {@link HostKeysBridge}.
671
664
  */
package/dist/index.js CHANGED
@@ -727,6 +727,9 @@ function createCoordinatedRelay(options) {
727
727
  }
728
728
 
729
729
  // src/keys-service-internals.ts
730
+ function actionRegistryKey(windowId, actionId) {
731
+ return JSON.stringify([windowId, actionId]);
732
+ }
730
733
  var MODIFIER_ALIASES = {
731
734
  ctrl: "ctrl",
732
735
  control: "ctrl",
@@ -795,7 +798,7 @@ function bindingsForWindow(windowId, registry, windowIndex) {
795
798
  if (!actionIds) return [];
796
799
  const bindings = [];
797
800
  for (const actionId of actionIds) {
798
- const entry = registry.get(actionId);
801
+ const entry = registry.get(actionRegistryKey(windowId, actionId));
799
802
  if (!entry || entry.windowId !== windowId) continue;
800
803
  bindings.push({ actionId, key: entry.chordString });
801
804
  }
@@ -808,10 +811,9 @@ function pushBindings(windowId, registry, windowIndex, send) {
808
811
  };
809
812
  send(payload);
810
813
  }
811
- function removeActionFromWindowIndex(actionId, windowIndex) {
812
- for (const [wid, set] of windowIndex.entries()) {
813
- if (set.delete(actionId) && set.size === 0) windowIndex.delete(wid);
814
- }
814
+ function removeActionFromWindowIndex(windowId, actionId, windowIndex) {
815
+ const actions = windowIndex.get(windowId);
816
+ if (actions?.delete(actionId) && actions.size === 0) windowIndex.delete(windowId);
815
817
  }
816
818
 
817
819
  // src/keys-service.ts
@@ -821,7 +823,7 @@ function rememberActionForWindow(windowIndex, windowId, actionId) {
821
823
  windowIndex.get(windowId).add(actionId);
822
824
  }
823
825
  function bindActionEntry(registry, windowIndex, actionId, windowId, chord, chordString) {
824
- registry.set(actionId, { chord, chordString, windowId });
826
+ registry.set(actionRegistryKey(windowId, actionId), { actionId, chord, chordString, windowId });
825
827
  rememberActionForWindow(windowIndex, windowId, actionId);
826
828
  }
827
829
  function bindActionAndMarkChanged(registry, windowIndex, changedWindowIds, actionId, windowId, chord, chordString) {
@@ -894,6 +896,7 @@ function createKeysService(options = {}) {
894
896
  const bridgeSendHandles = /* @__PURE__ */ new Map();
895
897
  const unsubscribeHandles = /* @__PURE__ */ new Map();
896
898
  const handleBridgeRegisterAction = (windowId, m, send) => {
899
+ const registryKey = actionRegistryKey(windowId, m.action.id);
897
900
  bridgeSendHandles.set(windowId, send);
898
901
  let binding;
899
902
  const changedWindowIds = /* @__PURE__ */ new Set();
@@ -901,8 +904,8 @@ function createKeysService(options = {}) {
901
904
  try {
902
905
  const chord = parseChord(m.action.defaultKey);
903
906
  const normalizedChord = formatChord(chord);
904
- const existingEntry = bridgeActionRegistry.get(m.action.id);
905
- const existing = unsubscribeHandles.get(m.action.id);
907
+ const existingEntry = bridgeActionRegistry.get(registryKey);
908
+ const existing = unsubscribeHandles.get(registryKey);
906
909
  let nextUnsubscribe;
907
910
  if (!isUnavailableBinding(chord)) {
908
911
  nextUnsubscribe = bridge.subscribe(normalizedChord, (ev) => {
@@ -928,13 +931,13 @@ function createKeysService(options = {}) {
928
931
  existing();
929
932
  } catch {
930
933
  }
931
- unsubscribeHandles.delete(m.action.id);
934
+ unsubscribeHandles.delete(registryKey);
932
935
  }
933
936
  if (existingEntry) changedWindowIds.add(existingEntry.windowId);
934
- removeActionFromWindowIndex(m.action.id, bridgeWindowActions);
935
- bridgeActionRegistry.delete(m.action.id);
937
+ removeActionFromWindowIndex(windowId, m.action.id, bridgeWindowActions);
938
+ bridgeActionRegistry.delete(registryKey);
936
939
  if (nextUnsubscribe) {
937
- unsubscribeHandles.set(m.action.id, nextUnsubscribe);
940
+ unsubscribeHandles.set(registryKey, nextUnsubscribe);
938
941
  binding = bindActionAndMarkChanged(
939
942
  bridgeActionRegistry,
940
943
  bridgeWindowActions,
@@ -964,18 +967,19 @@ function createKeysService(options = {}) {
964
967
  send
965
968
  );
966
969
  };
967
- const handleBridgeUnregisterAction = (_windowId, m, send) => {
970
+ const handleBridgeUnregisterAction = (windowId, m, send) => {
968
971
  if (!m.actionId) return;
969
- const unsubscribe = unsubscribeHandles.get(m.actionId);
970
- const entry = bridgeActionRegistry.get(m.actionId);
972
+ const registryKey = actionRegistryKey(windowId, m.actionId);
973
+ const unsubscribe = unsubscribeHandles.get(registryKey);
974
+ const entry = bridgeActionRegistry.get(registryKey);
971
975
  if (!unsubscribe) return;
972
976
  try {
973
977
  unsubscribe();
974
978
  } catch {
975
979
  }
976
- unsubscribeHandles.delete(m.actionId);
977
- bridgeActionRegistry.delete(m.actionId);
978
- removeActionFromWindowIndex(m.actionId, bridgeWindowActions);
980
+ unsubscribeHandles.delete(registryKey);
981
+ bridgeActionRegistry.delete(registryKey);
982
+ removeActionFromWindowIndex(windowId, m.actionId, bridgeWindowActions);
979
983
  if (entry) {
980
984
  const ownerSend = bridgeSendHandles.get(entry.windowId) ?? send;
981
985
  pushBindings(entry.windowId, bridgeActionRegistry, bridgeWindowActions, ownerSend);
@@ -995,15 +999,16 @@ function createKeysService(options = {}) {
995
999
  const actions = bridgeWindowActions.get(windowId);
996
1000
  if (!actions) return;
997
1001
  for (const actionId of actions) {
998
- const unsubscribe = unsubscribeHandles.get(actionId);
1002
+ const registryKey = actionRegistryKey(windowId, actionId);
1003
+ const unsubscribe = unsubscribeHandles.get(registryKey);
999
1004
  if (unsubscribe) {
1000
1005
  try {
1001
1006
  unsubscribe();
1002
1007
  } catch {
1003
1008
  }
1004
1009
  }
1005
- unsubscribeHandles.delete(actionId);
1006
- bridgeActionRegistry.delete(actionId);
1010
+ unsubscribeHandles.delete(registryKey);
1011
+ bridgeActionRegistry.delete(registryKey);
1007
1012
  }
1008
1013
  bridgeWindowActions.delete(windowId);
1009
1014
  bridgeSendHandles.delete(windowId);
@@ -1056,13 +1061,13 @@ function createKeysService(options = {}) {
1056
1061
  });
1057
1062
  }
1058
1063
  if (isReserved) return;
1059
- for (const [actionId, entry] of actionRegistry.entries()) {
1064
+ for (const entry of actionRegistry.values()) {
1060
1065
  if (chordMatches(entry.chord, ev)) {
1061
1066
  const send = sendHandles.get(entry.windowId);
1062
1067
  if (send) {
1063
1068
  const payload = {
1064
1069
  type: "keys.action",
1065
- actionId
1070
+ actionId: entry.actionId
1066
1071
  };
1067
1072
  send(payload);
1068
1073
  }
@@ -1071,6 +1076,7 @@ function createKeysService(options = {}) {
1071
1076
  };
1072
1077
  target.addEventListener("keydown", listener);
1073
1078
  const handleDocumentRegisterAction = (windowId, m, send) => {
1079
+ const registryKey = actionRegistryKey(windowId, m.action.id);
1074
1080
  sendHandles.set(windowId, send);
1075
1081
  let binding;
1076
1082
  const changedWindowIds = /* @__PURE__ */ new Set();
@@ -1078,11 +1084,11 @@ function createKeysService(options = {}) {
1078
1084
  try {
1079
1085
  const chord = parseChord(m.action.defaultKey);
1080
1086
  const normalizedChord = formatChord(chord);
1081
- const existing = actionRegistry.get(m.action.id);
1087
+ const existing = actionRegistry.get(registryKey);
1082
1088
  if (existing) {
1083
1089
  changedWindowIds.add(existing.windowId);
1084
- removeActionFromWindowIndex(m.action.id, windowActions);
1085
- actionRegistry.delete(m.action.id);
1090
+ removeActionFromWindowIndex(windowId, m.action.id, windowActions);
1091
+ actionRegistry.delete(registryKey);
1086
1092
  }
1087
1093
  if (!isUnavailableBinding(chord)) {
1088
1094
  binding = bindActionAndMarkChanged(actionRegistry, windowActions, changedWindowIds, m.action.id, windowId, chord, normalizedChord);
@@ -1100,10 +1106,12 @@ function createKeysService(options = {}) {
1100
1106
  sendRegisterActionResult(send, m, binding);
1101
1107
  pushChangedBindings(changedWindowIds, actionRegistry, windowActions, sendHandles, send);
1102
1108
  };
1103
- const handleDocumentUnregisterAction = (_windowId, m, send) => {
1104
- if (!m.actionId || !actionRegistry.has(m.actionId)) return;
1105
- const entry = actionRegistry.get(m.actionId);
1106
- actionRegistry.delete(m.actionId);
1109
+ const handleDocumentUnregisterAction = (windowId, m, send) => {
1110
+ if (!m.actionId) return;
1111
+ const registryKey = actionRegistryKey(windowId, m.actionId);
1112
+ const entry = actionRegistry.get(registryKey);
1113
+ if (!entry) return;
1114
+ actionRegistry.delete(registryKey);
1107
1115
  const set = windowActions.get(entry.windowId);
1108
1116
  if (set) {
1109
1117
  set.delete(m.actionId);
@@ -1123,7 +1131,7 @@ function createKeysService(options = {}) {
1123
1131
  onWindowDestroyed(windowId) {
1124
1132
  const actions = windowActions.get(windowId);
1125
1133
  if (actions) {
1126
- for (const actionId of actions) actionRegistry.delete(actionId);
1134
+ for (const actionId of actions) actionRegistry.delete(actionRegistryKey(windowId, actionId));
1127
1135
  windowActions.delete(windowId);
1128
1136
  }
1129
1137
  sendHandles.delete(windowId);