Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,64 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"`
}

// Constants for keyboard macro limits
const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
MinStepDelay = 50
MaxStepDelay = 2000
)

type KeyboardMacroStep struct {
Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"`
Delay int `json:"delay"`
}

func (s *KeyboardMacroStep) Validate() error {
if len(s.Keys) > MaxKeysPerStep {
return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
}

if s.Delay < MinStepDelay {
s.Delay = MinStepDelay
} else if s.Delay > MaxStepDelay {
s.Delay = MaxStepDelay
}

return nil
}

type KeyboardMacro struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []KeyboardMacroStep `json:"steps"`
SortOrder int `json:"sortOrder,omitempty"`
}

func (m *KeyboardMacro) Validate() error {
if m.Name == "" {
return fmt.Errorf("macro name cannot be empty")
}

if len(m.Steps) == 0 {
return fmt.Errorf("macro must have at least one step")
}

if len(m.Steps) > MaxStepsPerMacro {
return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
}

for i := range m.Steps {
if err := m.Steps[i].Validate(); err != nil {
return fmt.Errorf("invalid step %d: %w", i+1, err)
}
}

return nil
}

type Config struct {
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
Expand All @@ -26,6 +84,7 @@ type Config struct {
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"`
DisplayMaxBrightness int `json:"display_max_brightness"`
Expand All @@ -43,6 +102,7 @@ var defaultConfig = &Config{
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
Expand Down
95 changes: 95 additions & 0 deletions jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error {
return nil
}

func getKeyboardMacros() (interface{}, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)

return macros, nil
}

type KeyboardMacrosParams struct {
Macros []interface{} `json:"macros"`
}

func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter")
}

newMacros := make([]KeyboardMacro, 0, len(params.Macros))

for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i)
}

id, _ := macroMap["id"].(string)
if id == "" {
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
}

name, _ := macroMap["name"].(string)

sortOrder := i + 1
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
sortOrder = int(sortOrderFloat)
}

steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{})
if !ok {
continue
}

step := KeyboardMacroStep{}

if keysArray, ok := stepMap["keys"].([]interface{}); ok {
for _, k := range keysArray {
if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr)
}
}
}

if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
for _, m := range modsArray {
if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr)
}
}
}

if delay, ok := stepMap["delay"].(float64); ok {
step.Delay = int(delay)
}

steps = append(steps, step)
}
}

macro := KeyboardMacro{
ID: id,
Name: name,
Steps: steps,
SortOrder: sortOrder,
}

if err := macro.Validate(); err != nil {
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
}

newMacros = append(newMacros, macro)
}

config.KeyboardMacros = newMacros

if err := SaveConfig(); err != nil {
return nil, err
}

return nil, nil
}

var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
Expand Down Expand Up @@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
}
4 changes: 2 additions & 2 deletions ui/src/components/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ type CheckBoxProps = {
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;

const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", ...props },
{ size = "MD", className, ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={classes} />;
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
});
Checkbox.displayName = "Checkbox";

Expand Down
119 changes: 119 additions & 0 deletions ui/src/components/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useRef } from "react";
import clsx from "clsx";
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import { cva } from "@/cva.config";
import Card from "./Card";

export interface ComboboxOption {
value: string;
label: string;
}

const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
} as const;

const comboboxVariants = cva({
variants: { size: sizes },
});

type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;

interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void;
options: () => ComboboxOption[];
placeholder?: string;
emptyMessage?: string;
size?: keyof typeof sizes;
disabledMessage?: string;
}

export function Combobox({
onInputChange,
displayValue,
options,
disabled = false,
placeholder = "Search...",
emptyMessage = "No results found",
size = "MD",
onChange,
disabledMessage = "Input disabled",
...otherProps
}: ComboboxProps) {
const inputRef = useRef<HTMLInputElement>(null);
const classes = comboboxVariants({ size });

return (
<HeadlessCombobox
onChange={onChange}
{...otherProps}
>
{() => (
<>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<ComboboxInput
ref={inputRef}
className={clsx(
classes,

// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",

// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60",

// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",

// Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",

// Disabled
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
)}
placeholder={disabled ? disabledMessage : placeholder}
displayValue={displayValue}
onChange={(event) => onInputChange(event.target.value)}
disabled={disabled}
/>
</Card>

{options().length > 0 && (
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
{options().map((option) => (
<ComboboxOption
key={option.value}
value={option}
className={clsx(
// General styling
"cursor-default select-none py-2 px-4",

// Hover and active states
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",

// Dark mode
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
)}
>
{option.label}
</ComboboxOption>
))}
</ComboboxOptions>
)}

{options().length === 0 && inputRef.current?.value && (
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400">
{emptyMessage}
</div>
</div>
)}
</>
)}
</HeadlessCombobox>
);
}
Loading