diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx
index 0d781af6..39e57cdd 100644
--- a/ui/src/routes/devices.$id.settings.macros.tsx
+++ b/ui/src/routes/devices.$id.settings.macros.tsx
@@ -23,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();
@@ -120,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(
() => (
@@ -244,27 +274,7 @@ export default function SettingsMacrosRoute() {
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
/>
-
),
- [
- 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 (
@@ -326,57 +324,39 @@ export default function SettingsMacrosRoute() {
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++; }
- }
+ {
+ const fl = e.target.files;
+ if (!fl || fl.length === 0) return;
+ let working = [...macros];
+ const imported: string[] = [];
+ let errors = 0;
+ let skipped = 0;
+ for (const f of Array.from(fl)) {
+ if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
try {
- if (imported.length) {
- await saveMacros(normalizeSortOrders(working));
- notifications.success(`Imported ${imported.length} macro${imported.length===1?'':'s'}`);
+ 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 - candidates.indexOf(c)); break; }
+ if (!c || typeof c !== "object") { errors++; continue; }
+ const sanitized = sanitizeImportedMacro(c, working.length + 1);
+ working.push(sanitized);
+ imported.push(sanitized.name);
}
- 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 = '';
+ } 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 = '';
+ }
+ }} />