diff --git a/config.go b/config.go
index c83ccfc7..f89ff936 100644
--- a/config.go
+++ b/config.go
@@ -24,7 +24,7 @@ const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
- MinStepDelay = 50
+ MinStepDelay = 10
MaxStepDelay = 2000
)
@@ -32,6 +32,10 @@ type KeyboardMacroStep struct {
Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"`
Delay int `json:"delay"`
+ // Optional: when set, this step types the given text using the configured keyboard layout.
+ // The delay value is treated as the per-character delay.
+ Text string `json:"text,omitempty"`
+ Wait bool `json:"wait,omitempty"`
}
func (s *KeyboardMacroStep) Validate() error {
diff --git a/jsonrpc.go b/jsonrpc.go
index 0ff44a78..301bf029 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -1038,6 +1038,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
step.Delay = int(delay)
}
+ // Optional text field for advanced steps
+ if txt, ok := stepMap["text"].(string); ok {
+ step.Text = txt
+ }
+
+ if wv, ok := stepMap["wait"].(bool); ok {
+ step.Wait = wv
+ }
+
steps = append(steps, step)
}
}
diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx
index 1aafe9c9..c5be524e 100644
--- a/ui/src/components/MacroForm.tsx
+++ b/ui/src/components/MacroForm.tsx
@@ -66,7 +66,7 @@ export function MacroForm({
newErrors.steps = { 0: { keys: "At least one step is required" } };
} else {
const hasKeyOrModifier = macro.steps.some(
- step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
+ step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
);
if (!hasKeyOrModifier) {
@@ -163,6 +163,40 @@ export function MacroForm({
setMacro({ ...macro, steps: newSteps });
};
+ const handleStepTypeChange = (stepIndex: number, type: "keys" | "text" | "wait") => {
+ const newSteps = [...(macro.steps || [])];
+ const prev = newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY };
+ if (type === "text") {
+ newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, text: prev.text || "" } as any;
+ } else if (type === "wait") {
+ newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, wait: true } as any;
+ } else {
+ // switch back to keys; drop text
+ const { text, wait, ...rest } = prev as any;
+ newSteps[stepIndex] = { ...rest } as any;
+ }
+ setMacro({ ...macro, steps: newSteps });
+ };
+
+ const handleTextChange = (stepIndex: number, text: string) => {
+ const newSteps = [...(macro.steps || [])];
+ // Ensure this step is of text type
+ newSteps[stepIndex] = { ...(newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY }), text } as any;
+ setMacro({ ...macro, steps: newSteps });
+ };
+
+ const insertStepAfter = (index: number) => {
+ if (isMaxStepsReached) {
+ showTemporaryError(
+ `You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
+ );
+ return;
+ }
+ const newSteps = [...(macro.steps || [])];
+ newSteps.splice(index + 1, 0, { keys: [], modifiers: [], delay: DEFAULT_DELAY });
+ setMacro(prev => ({ ...prev, steps: newSteps }));
+ };
+
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
const newSteps = [...(macro.steps || [])];
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
@@ -213,31 +247,46 @@ export function MacroForm({
diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx
index c795520d..5c9b0d9a 100644
--- a/ui/src/components/MacroStepCard.tsx
+++ b/ui/src/components/MacroStepCard.tsx
@@ -38,16 +38,22 @@ const basePresetDelays = [
];
const PRESET_DELAYS = basePresetDelays.map(delay => {
- if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
- return { ...delay, label: "Default" };
- }
+ if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" };
return delay;
});
+const TEXT_EXTRA_DELAYS = [
+ { value: "10", label: "10ms" },
+ { value: "20", label: "20ms" },
+ { value: "30", label: "30ms" },
+];
+
interface MacroStep {
keys: string[];
modifiers: string[];
delay: number;
+ text?: string;
+ wait?: boolean;
}
interface MacroStepCardProps {
@@ -62,7 +68,9 @@ interface MacroStepCardProps {
onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void;
isLastStep: boolean;
- keyboard: KeyboardLayout
+ keyboard: KeyboardLayout;
+ onStepTypeChange: (type: "keys" | "text" | "wait") => void;
+ onTextChange: (text: string) => void;
}
const ensureArray = (arr: T[] | null | undefined): T[] => {
@@ -81,7 +89,9 @@ export function MacroStepCard({
onModifierChange,
onDelayChange,
isLastStep,
- keyboard
+ keyboard,
+ onStepTypeChange,
+ onTextChange,
}: MacroStepCardProps) {
const { keyDisplayMap } = keyboard;
@@ -106,6 +116,8 @@ export function MacroStepCard({
}
}, [keyOptions, keyQuery, step.keys]);
+ const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys");
+
return (
@@ -146,6 +158,46 @@ export function MacroStepCard({
+
+
+
+
+
+ {stepType === "text" ? (
+
+
+ onTextChange(e.target.value)}
+ placeholder="Enter text..."
+ />
+
+ ) : stepType === "wait" ? (
+
+
+
This step waits for the configured duration, no keys are sent.
+
+ ) : (
@@ -176,7 +228,8 @@ export function MacroStepCard({
))}
-
+ )}
+ {stepType === "keys" && (
@@ -223,10 +276,10 @@ export function MacroStepCard({
/>
-
+ )}
-
+
onDelayChange(parseInt(e.target.value, 10))}
- options={PRESET_DELAYS}
+ options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS}
/>
+
);
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index bfbbb26e..967f569a 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -763,6 +763,8 @@ export interface KeySequenceStep {
keys: string[];
modifiers: string[];
delay: number;
+ text?: string; // optional: when set, type this text with per-character delay
+ wait?: boolean; // optional: when true, this is a pure wait step (pause for delay ms)
}
export interface KeySequence {
diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts
index 8d101b3b..5d90798e 100644
--- a/ui/src/hooks/useKeyboard.ts
+++ b/ui/src/hooks/useKeyboard.ts
@@ -16,6 +16,7 @@ import {
import { useHidRpc } from "@/hooks/useHidRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
+import useKeyboardLayout from "@/hooks/useKeyboardLayout";
const MACRO_RESET_KEYBOARD_STATE = {
keys: new Array(hidKeyBufferSize).fill(0),
@@ -27,6 +28,8 @@ export interface MacroStep {
keys: string[] | null;
modifiers: string[] | null;
delay: number;
+ text?: string | undefined;
+ wait?: boolean | undefined;
}
export type MacroSteps = MacroStep[];
@@ -34,6 +37,7 @@ export type MacroSteps = MacroStep[];
const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms));
export default function useKeyboard() {
+ const { selectedKeyboard } = useKeyboardLayout();
const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } =
@@ -284,9 +288,38 @@ export default function useKeyboard() {
// After the delay, the keys and modifiers are released and the next step is executed.
// If a step has no keys or modifiers, it is treated as a delay-only step.
// A small pause is added between steps to ensure that the device can process the events.
+ const expandTextSteps = useCallback((steps: MacroSteps): MacroSteps => {
+ const expanded: MacroSteps = [];
+ for (const step of steps) {
+ if (step.text && step.text.length > 0 && selectedKeyboard) {
+ for (const char of step.text) {
+ const keyprops = selectedKeyboard.chars[char];
+ if (!keyprops) continue;
+ const { key, shift, altRight, deadKey, accentKey } = keyprops;
+ if (!key) continue;
+ if (accentKey) {
+ const accentModifiers: string[] = [];
+ if (accentKey.shift) accentModifiers.push("ShiftLeft");
+ if (accentKey.altRight) accentModifiers.push("AltRight");
+ expanded.push({ keys: [String(accentKey.key)], modifiers: accentModifiers, delay: step.delay });
+ }
+ const mods: string[] = [];
+ if (shift) mods.push("ShiftLeft");
+ if (altRight) mods.push("AltRight");
+ expanded.push({ keys: [String(key)], modifiers: mods, delay: step.delay });
+ if (deadKey) expanded.push({ keys: ["Space"], modifiers: null, delay: step.delay });
+ }
+ } else {
+ expanded.push(step);
+ }
+ }
+ return expanded;
+ }, [selectedKeyboard]);
+
const executeMacroRemote = useCallback(async (
- steps: MacroSteps,
+ stepsIn: MacroSteps,
) => {
+ const steps = expandTextSteps(stepsIn);
const macro: KeyboardMacroStep[] = [];
for (const [_, step] of steps.entries()) {
@@ -297,16 +330,22 @@ export default function useKeyboard() {
.reduce((acc, val) => acc + val, 0);
- // If the step has keys and/or modifiers, press them and hold for the delay
- if (keyValues.length > 0 || modifierMask > 0) {
+ if (step.wait) {
+ // pure wait: send a no-op clear state with desired delay
+ macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
+ } else if (keyValues.length > 0 || modifierMask > 0) {
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
+ } else {
+ // empty step (pause only)
+ macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
}
}
sendKeyboardMacroEventHidRpc(macro);
- }, [sendKeyboardMacroEventHidRpc]);
- const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
+ }, [sendKeyboardMacroEventHidRpc, expandTextSteps]);
+ const executeMacroClientSide = useCallback(async (stepsIn: MacroSteps) => {
+ const steps = expandTextSteps(stepsIn);
const promises: (() => Promise)[] = [];
const ac = new AbortController();
@@ -318,11 +357,14 @@ export default function useKeyboard() {
.map(mod => modifiers[mod])
.reduce((acc, val) => acc + val, 0);
- // If the step has keys and/or modifiers, press them and hold for the delay
- if (keyValues.length > 0 || modifierMask > 0) {
+ if (step.wait) {
+ promises.push(() => sleep(step.delay || 100));
+ } else if (keyValues.length > 0 || modifierMask > 0) {
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
promises.push(() => resetKeyboardState());
promises.push(() => sleep(step.delay || 100));
+ } else {
+ promises.push(() => sleep(step.delay || 100));
}
}
@@ -354,7 +396,7 @@ export default function useKeyboard() {
reject(error);
});
});
- }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]);
+ }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController, expandTextSteps]);
const executeMacro = useCallback(async (steps: MacroSteps) => {
if (rpcHidReady) {
return executeMacroRemote(steps);
diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx
index 94fded36..39e57cdd 100644
--- a/ui/src/routes/devices.$id.settings.macros.tsx
+++ b/ui/src/routes/devices.$id.settings.macros.tsx
@@ -1,4 +1,4 @@
-import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
+import { useEffect, Fragment, useMemo, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router";
import {
LuPenLine,
@@ -9,6 +9,7 @@ import {
LuArrowDown,
LuTrash2,
LuCommand,
+ LuDownload,
} from "react-icons/lu";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
@@ -22,13 +23,32 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import LoadingSpinner from "@/components/LoadingSpinner";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
-const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
- return macros.map((macro, index) => ({
- ...macro,
- sortOrder: index + 1,
- }));
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => macros.map((m, i) => ({ ...m, sortOrder: i + 1 }));
+
+const pad2 = (n: number) => String(n).padStart(2, "0");
+
+const buildMacroDownloadFilename = (macro: KeySequence) => {
+ const safeName = (macro.name || macro.id).replace(/[^a-z0-9-_]+/gi, "-").toLowerCase();
+ const now = new Date();
+ const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
+ return `jetkvm-macro-${safeName}-${ts}.json`;
};
+const sanitizeImportedStep = (raw: any) => ({
+ keys: Array.isArray(raw?.keys) ? raw.keys.filter((k: any) => typeof k === "string") : [],
+ modifiers: Array.isArray(raw?.modifiers) ? raw.modifiers.filter((m: any) => typeof m === "string") : [],
+ delay: typeof raw?.delay === "number" ? raw.delay : DEFAULT_DELAY,
+ text: typeof raw?.text === "string" ? raw.text : undefined,
+ wait: typeof raw?.wait === "boolean" ? raw.wait : false,
+});
+
+const sanitizeImportedMacro = (raw: any, sortOrder: number): KeySequence => ({
+ id: generateMacroId(),
+ name: (typeof raw?.name === "string" && raw.name.trim() ? raw.name : "Imported Macro").slice(0, 50),
+ steps: Array.isArray(raw?.steps) ? raw.steps.map(sanitizeImportedStep) : [],
+ sortOrder,
+});
+
export default function SettingsMacrosRoute() {
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
const navigate = useNavigate();
@@ -36,6 +56,7 @@ export default function SettingsMacrosRoute() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [macroToDelete, setMacroToDelete] = useState(null);
const { selectedKeyboard } = useKeyboardLayout();
+ const fileInputRef = useRef(null);
const isMaxMacrosReached = useMemo(
() => macros.length >= MAX_TOTAL_MACROS,
@@ -48,74 +69,52 @@ export default function SettingsMacrosRoute() {
}
}, [initialized, loadMacros]);
- const handleDuplicateMacro = useCallback(
- async (macro: KeySequence) => {
- if (!macro?.id || !macro?.name) {
- notifications.error("Invalid macro data");
- return;
- }
+ const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
+ if (!macro?.id || !macro?.name) {
+ notifications.error("Invalid macro data");
+ return;
+ }
+ if (isMaxMacrosReached) {
+ notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+ return;
+ }
+ setActionLoadingId(macro.id);
+ const newMacroCopy: KeySequence = {
+ ...JSON.parse(JSON.stringify(macro)),
+ id: generateMacroId(),
+ name: `${macro.name} ${COPY_SUFFIX}`,
+ sortOrder: macros.length + 1,
+ };
+ try {
+ await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+ notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+ } catch (e: any) {
+ notifications.error(`Failed to duplicate macro: ${e?.message || 'error'}`);
+ } finally {
+ setActionLoadingId(null);
+ }
+ }, [macros, saveMacros, isMaxMacrosReached]);
- if (isMaxMacrosReached) {
- notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
- return;
- }
-
- setActionLoadingId(macro.id);
-
- const newMacroCopy: KeySequence = {
- ...JSON.parse(JSON.stringify(macro)),
- id: generateMacroId(),
- name: `${macro.name} ${COPY_SUFFIX}`,
- sortOrder: macros.length + 1,
- };
-
- try {
- await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
- notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
- } catch (error: unknown) {
- if (error instanceof Error) {
- notifications.error(`Failed to duplicate macro: ${error.message}`);
- } else {
- notifications.error("Failed to duplicate macro");
- }
- } finally {
- setActionLoadingId(null);
- }
- },
- [isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
- );
-
- const handleMoveMacro = useCallback(
- async (index: number, direction: "up" | "down", macroId: string) => {
- if (!Array.isArray(macros) || macros.length === 0) {
- notifications.error("No macros available");
- return;
- }
-
- const newIndex = direction === "up" ? index - 1 : index + 1;
- if (newIndex < 0 || newIndex >= macros.length) return;
-
- setActionLoadingId(macroId);
-
- try {
- const newMacros = [...macros];
- [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
- const updatedMacros = normalizeSortOrders(newMacros);
-
- await saveMacros(updatedMacros);
- notifications.success("Macro order updated successfully");
- } catch (error: unknown) {
- if (error instanceof Error) {
- notifications.error(`Failed to reorder macros: ${error.message}`);
- } else {
- notifications.error("Failed to reorder macros");
- }
- } finally {
- setActionLoadingId(null);
- }
- },
- [macros, saveMacros, setActionLoadingId],
- );
+ const handleMoveMacro = useCallback(async (index: number, direction: "up" | "down", macroId: string) => {
+ if (!Array.isArray(macros) || macros.length === 0) {
+ notifications.error("No macros available");
+ return;
+ }
+ const newIndex = direction === "up" ? index - 1 : index + 1;
+ if (newIndex < 0 || newIndex >= macros.length) return;
+ setActionLoadingId(macroId);
+ try {
+ const newMacros = [...macros];
+ [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+ const updatedMacros = normalizeSortOrders(newMacros);
+ await saveMacros(updatedMacros);
+ notifications.success("Macro order updated successfully");
+ } catch (e: any) {
+ notifications.error(`Failed to reorder macros: ${e?.message || 'error'}`);
+ } finally {
+ setActionLoadingId(null);
+ }
+ }, [macros, saveMacros]);
const handleDeleteMacro = useCallback(async () => {
if (!macroToDelete?.id) return;
@@ -140,6 +139,17 @@ export default function SettingsMacrosRoute() {
}
}, [macroToDelete, macros, saveMacros]);
+ const handleDownloadMacro = useCallback((macro: KeySequence) => {
+ const data = JSON.stringify(macro, null, 2);
+ const blob = new Blob([data], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = buildMacroDownloadFilename(macro);
+ a.click();
+ URL.revokeObjectURL(url);
+ }, []);
+
const MacroList = useMemo(
() => (
@@ -178,9 +188,12 @@ export default function SettingsMacrosRoute() {
- {(Array.isArray(step.modifiers) &&
- step.modifiers.length > 0) ||
- (Array.isArray(step.keys) && step.keys.length > 0) ? (
+ {step.text && step.text.length > 0 ? (
+ Text: "{step.text}"
+ ) : step.wait ? (
+ Wait
+ ) : (Array.isArray(step.modifiers) && step.modifiers.length > 0) ||
+ (Array.isArray(step.keys) && step.keys.length > 0) ? (
<>
{Array.isArray(step.modifiers) &&
step.modifiers.map((modifier, idx) => (
@@ -224,7 +237,7 @@ export default function SettingsMacrosRoute() {
>
) : (
- Delay only
+ Pause only
)}
{step.delay !== DEFAULT_DELAY && (
@@ -261,6 +274,7 @@ export default function SettingsMacrosRoute() {
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
/>
+ handleDownloadMacro(macro)} aria-label={`Download macro ${macro.name}`} disabled={actionLoadingId === macro.id} />
),
- [
- macros,
- showDeleteConfirm,
- macroToDelete?.name,
- macroToDelete?.id,
- actionLoadingId,
- handleDeleteMacro,
- handleMoveMacro,
- selectedKeyboard.modifierDisplayMap,
- selectedKeyboard.keyDisplayMap,
- handleDuplicateMacro,
- navigate
- ],
+ [macros, showDeleteConfirm, macroToDelete?.name, macroToDelete?.id, actionLoadingId, handleDeleteMacro, handleMoveMacro, selectedKeyboard.modifierDisplayMap, selectedKeyboard.keyDisplayMap, handleDuplicateMacro, navigate, handleDownloadMacro],
);
return (
@@ -312,18 +314,57 @@ export default function SettingsMacrosRoute() {
title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`}
/>
- {macros.length > 0 && (
-