cjpais/Handy · error · Error

Failed to update binding

Error message

Failed to update binding

What it means

The changeBinding command executed (result.status === "ok") but the backend returned a BindingResponse with success:false — the handler refused to apply the new binding and put the reason in data.error (typed `string | null`). The generic "Failed to update binding" text only appears when the backend left error null; known success:false paths include an id that exists neither in settings nor in defaults (shortcut/mod.rs:140), and shortcut registration/unregistration failures.

Solutions

  1. Inspect result.data.error — it usually names the real cause; surface it in the UI instead of the generic fallback text
  2. Verify the id passed is a real shortcut id from settings.bindings or the defaults map
  3. Pick a key combo not already used by another binding or a system/global shortcut of another app
  4. On macOS grant Accessibility (and Input Monitoring) permission to the app, then retry
  5. Retry after the automatic rollback restores the original binding

Example fix

// before
if (!result.data.success) {
  throw new Error(result.data.error || "Failed to update binding");
}

// after — always carry actionable context even when the backend omits data.error
if (!result.data.success) {
  throw new Error(
    result.data.error ?? `Backend rejected binding ${id}=${binding}: success=false with no error detail`
  );
}
Defensive patterns

Strategy: try-catch

Validate before calling

// reject ids the backend doesn't know and combos already claimed by another binding
const bindings = get().settings?.bindings ?? {};
if (!(id in bindings) && !DEFAULT_BINDING_IDS.includes(id)) {
  throw new Error(`Unknown binding id: ${id}`);
}
const comboInUse = Object.entries(bindings).find(
  ([otherId, b]) => otherId !== id && b.current_binding === binding
);
if (comboInUse) {
  throw new Error(`Combo already used by: ${comboInUse[0]}`);
}

Type guard

export function isBindingRejected(
  r: Result<BindingResponse, string>
): r is { status: "ok"; data: BindingResponse } {
  return r.status === "ok" && r.data.success === false;
}

Try / catch

try {
  const result = await commands.changeBinding(id, binding);
  if (result.status === "ok" && !result.data.success) {
    // prefer the backend's reason; only fall back to generic text when error is null
    throw new Error(result.data.error ?? `Backend rejected binding ${id}`);
  }
} catch (error) {
  // rollback (already implemented) + show the specific reason to the user
}

Prevention

When it happens

Trigger: Calling changeBinding with an unknown shortcut id (not in settings.bindings and not in get_default_settings()); the new combo failing OS-level registration in register_shortcut (device/permission problems or an already-registered accelerator); failing to unregister the previous binding before applying the new one.

Common situations: Typo'd or renamed shortcut id in frontend code; duplicate key combos claimed by another binding or another app; missing accessibility/input-monitoring permissions on macOS; rapidly editing the same binding from two windows.

Related errors


AI-assisted analysis of cjpais/Handy@fbd4e15fa1 (2026-08-17). Data as JSON: /api/errors/42e5aa26649b3ce4. Report an issue: GitHub.

Appendix: source

Thrown at src/stores/settingsStore.ts:389

                  [id]: {
                    ...state.settings.bindings?.[id]!,
                    current_binding: binding,
                  },
                },
              }
            : null,
        }));

        const result = await commands.changeBinding(id, binding);

        // Check if the command executed successfully
        if (result.status === "error") {
          throw new Error(result.error);
        }

        // Check if the binding change was successful
        if (!result.data.success) {
          throw new Error(result.data.error || "Failed to update binding");
        }
      } catch (error) {
        console.error(`Failed to update binding ${id}:`, error);

        // Rollback on error
        if (originalBinding && get().settings) {
          set((state) => ({
            settings: state.settings
              ? {
                  ...state.settings,
                  bindings: {
                    ...state.settings.bindings,
                    [id]: {
                      ...state.settings.bindings?.[id]!,
                      current_binding: originalBinding,
                    },
                  },
                }

View on GitHub (pinned to fbd4e15fa1)