diff --git a/config.go b/config.go
index 680999a3..9cf138cd 100644
--- a/config.go
+++ b/config.go
@@ -23,7 +23,7 @@ const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
- MinStepDelay = 50
+ MinStepDelay = 10
MaxStepDelay = 2000
)
@@ -31,6 +31,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 3e3d9c94..4b328874 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -1024,6 +1024,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 cf22468b..1c85b79f 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..0d781af6 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";
@@ -36,6 +37,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 +50,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;
@@ -178,9 +158,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 +207,7 @@ export default function SettingsMacrosRoute() {
>
) : (
- Delay only
+ Pause only
)}
{step.delay !== DEFAULT_DELAY && (
@@ -261,6 +244,27 @@ export default function SettingsMacrosRoute() {
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
/>
+ {
+ 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");
+ const safeName = macro.name.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase();
+ const now = new Date();
+ const pad = (n: number) => String(n).padStart(2, "0");
+ const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
+ a.href = url;
+ a.download = `jetkvm-macro-${safeName || macro.id}-${ts}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }}
+ aria-label={`Download macro ${macro.name}`}
+ disabled={actionLoadingId === macro.id}
+ />
- {macros.length > 0 && (
-
+
+
navigate("add")}
+ disabled={isMaxMacrosReached}
+ aria-label="Add new macro"
+ />
+
+ {
+ const files = e.target.files;
+ if (!files || files.length === 0) return;
+ let working = [...macros];
+ const imported: string[] = [];
+ let errors = 0;
+ let skipped = 0;
+ for (const f of Array.from(files)) {
+ if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
+ try {
+ const raw = await f.text();
+ const parsed = JSON.parse(raw);
+ const candidates = Array.isArray(parsed) ? parsed : [parsed];
+ for (const c of candidates) {
+ if (working.length >= MAX_TOTAL_MACROS) { skipped += (candidates.length); break; }
+ if (!c || typeof c !== 'object') { errors++; continue; }
+ const sanitized: KeySequence = {
+ id: generateMacroId(),
+ name: (c.name || 'Imported Macro').slice(0,50),
+ steps: Array.isArray(c.steps) ? c.steps.map((s:any) => ({
+ keys: Array.isArray(s.keys) ? s.keys : [],
+ modifiers: Array.isArray(s.modifiers) ? s.modifiers : [],
+ delay: typeof s.delay === 'number' ? s.delay : DEFAULT_DELAY,
+ text: typeof s.text === 'string' ? s.text : undefined,
+ wait: typeof s.wait === 'boolean' ? s.wait : false,
+ })) : [],
+ sortOrder: working.length + 1,
+ };
+ working.push(sanitized);
+ imported.push(sanitized.name);
+ }
+ } catch { errors++; }
+ }
+ try {
+ if (imported.length) {
+ await saveMacros(normalizeSortOrders(working));
+ notifications.success(`Imported ${imported.length} macro${imported.length===1?'':'s'}`);
+ }
+ if (errors) notifications.error(`${errors} file${errors===1?'':'s'} failed`);
+ if (skipped) notifications.error(`${skipped} macro${skipped===1?'':'s'} skipped (limit ${MAX_TOTAL_MACROS})`);
+ } finally {
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ }}
+ />
navigate("add")}
- disabled={isMaxMacrosReached}
- aria-label="Add new macro"
+ theme="light"
+ text="Import Macro"
+ onClick={() => fileInputRef.current?.click()}
/>
- )}
+