Compare commits

...

7 Commits

Author SHA1 Message Date
Ben Kochie f1953fddbc
chore: add metrics for configuration and WOL (#193)
* Configuration load success/timestamp.
* Wake-on-Lan packets/errors.

Signed-off-by: SuperQ <superq@gmail.com>
2025-07-11 18:14:32 +02:00
Marc Brooks 9ba97ebe67
chore(ui): Clean new keyboard option (#495)
Fixed the Tailwind CSS syntax for `in` (nested) selector
Added missing React dependency for `useEffect`
2025-07-11 17:56:03 +02:00
Marc Brooks 5fb8d866ba
refactor(ui): Refactor the keyboardLayouts (#497)
Add missing keyboard mappings for most layouts
Change  pasteModel.tsx to use the new structure and vastly clarified the way that keys are emitted.
Make each layout export just the KeyboardLayout object (which is a package of isoCode, name, and chars)
Made keyboardLayouts.ts export a function to select keyboard by `isoCode`, export the keyboards as label . value pairs (for a select list) and the list of keyboards.
Changed devices.$id.settings.keyboard.tsx use the exported keyboard option list.
2025-07-11 17:49:06 +02:00
rmschooley 3359f8fca4
Remove Out Endpoint Descriptors from Absolute Mouse and Relative Mouse (#542)
* Update hid_mouse_absolute.go

Added attribute to remove unnecessary out endpoint.

* Update hid_mouse_relative.go

Added attribute to remove unnecessary out endpoint.

* Update hid_keyboard.go

Added attribute to explicitly keep currently needed out endpoint and to make listed attributes consistent across the keyboard and mouse devices.

---------

Co-authored-by: Aveline <352441+ym@users.noreply.github.com>
2025-07-11 17:43:37 +02:00
Daniel Collins ef95643a86
Implement HTTP proxy option (#515). (#521)
This commit adds a "Proxy" field to the network settings screen, which
can be used to specify a HTTP proxy for any outgoing requests from the
device.
2025-07-11 17:43:22 +02:00
Daniel Collins 1fc603b553
Add -i/--install option to dev_deploy.sh (#527)
Running `dev_deploy.sh -i` will build the app in release mode and
install it to the device for longer term development/testing or just
running a custom variant of the app.
2025-07-11 17:09:49 +02:00
Bradley Wilson-Hunt aada3d95e0
feat(metrics): adding prometheus metrics for dc power extension (#556) 2025-07-11 17:04:41 +02:00
34 changed files with 452 additions and 184 deletions

View File

@ -9,6 +9,8 @@ import (
"github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
) )
type WakeOnLanDevice struct { type WakeOnLanDevice struct {
@ -138,6 +140,21 @@ var (
configLock = &sync.Mutex{} configLock = &sync.Mutex{}
) )
var (
configSuccess = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_successful",
Help: "The last configuration load succeeded",
},
)
configSuccessTime = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_success_timestamp_seconds",
Help: "Timestamp of last successful config load",
},
)
)
func LoadConfig() { func LoadConfig() {
configLock.Lock() configLock.Lock()
defer configLock.Unlock() defer configLock.Unlock()
@ -153,6 +170,8 @@ func LoadConfig() {
file, err := os.Open(configPath) file, err := os.Open(configPath)
if err != nil { if err != nil {
logger.Debug().Msg("default config file doesn't exist, using default") logger.Debug().Msg("default config file doesn't exist, using default")
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
return return
} }
defer file.Close() defer file.Close()
@ -161,6 +180,7 @@ func LoadConfig() {
loadedConfig := *defaultConfig loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed") logger.Warn().Err(err).Msg("config file JSON parsing failed")
configSuccess.Set(0.0)
return return
} }
@ -181,6 +201,9 @@ func LoadConfig() {
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
logger.Info().Str("path", configPath).Msg("config loaded") logger.Info().Str("path", configPath).Msg("config loaded")
} }

53
dc_metrics.go Normal file
View File

@ -0,0 +1,53 @@
package kvm
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
var (
dcCurrentGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_current_amperes",
Help: "Current DC power consumption in amperes",
})
dcPowerGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_power_watts",
Help: "DC power consumption in watts",
})
dcVoltageGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_voltage_volts",
Help: "DC voltage in volts",
})
dcStateGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_power_state",
Help: "DC power state (1 = on, 0 = off)",
})
dcMetricsRegistered sync.Once
)
// registerDCMetrics registers the DC power metrics with Prometheus (called once when DC control is mounted)
func registerDCMetrics() {
dcMetricsRegistered.Do(func() {
prometheus.MustRegister(dcCurrentGauge)
prometheus.MustRegister(dcPowerGauge)
prometheus.MustRegister(dcVoltageGauge)
prometheus.MustRegister(dcStateGauge)
})
}
// updateDCMetrics updates the Prometheus metrics with current DC power state values
func updateDCMetrics(state DCPowerState) {
dcCurrentGauge.Set(state.Current)
dcPowerGauge.Set(state.Power)
dcVoltageGauge.Set(state.Voltage)
if state.IsOn {
dcStateGauge.Set(1)
} else {
dcStateGauge.Set(0)
}
}

View File

@ -28,6 +28,7 @@ show_help() {
echo " --run-go-tests Run go tests" echo " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit" echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build" echo " --skip-ui-build Skip frontend/UI build"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message" echo " --help Display this help message"
echo echo
echo "Example:" echo "Example:"
@ -43,6 +44,7 @@ RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false RUN_GO_TESTS=false
RUN_GO_TESTS_ONLY=false RUN_GO_TESTS_ONLY=false
INSTALL_APP=false
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -72,6 +74,10 @@ while [[ $# -gt 0 ]]; do
RUN_GO_TESTS=true RUN_GO_TESTS=true
shift shift
;; ;;
-i|--install)
INSTALL_APP=true
shift
;;
--help) --help)
show_help show_help
exit 0 exit 0
@ -139,25 +145,36 @@ EOF
fi fi
fi fi
msg_info "▶ Building go binary" if [ "$INSTALL_APP" = true ]
make build_dev then
msg_info "▶ Building release binary"
# Kill any existing instances of the application make build_release
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host as if we were the OTA updater.
# Copy the binary to the remote host ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process.
if [ "$RESET_USB_HID_DEVICE" = true ]; then ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
msg_info "▶ Resetting USB HID device" else
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed" msg_info "▶ Building development binary"
# Remove the old USB gadget configuration make build_dev
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC" # Kill any existing instances of the application
fi ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Deploy and run the application on the remote host # Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e set -e
# Set the library path to include the directory where librockit.so is located # Set the library path to include the directory where librockit.so is located
@ -176,5 +193,6 @@ chmod +x jetkvm_app_debug
# Run the application in the background # Run the application in the background
PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log
EOF EOF
fi
echo "Deployment complete." echo "Deployment complete."

View File

@ -3,6 +3,7 @@ package confparser
import ( import (
"fmt" "fmt"
"net" "net"
"net/url"
"reflect" "reflect"
"slices" "slices"
"strconv" "strconv"
@ -372,6 +373,10 @@ func (f *FieldConfig) validateField() error {
if _, err := idna.Lookup.ToASCII(val); err != nil { if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val) return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
} }
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
}
default: default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
} }

View File

@ -3,6 +3,8 @@ package network
import ( import (
"fmt" "fmt"
"net" "net"
"net/http"
"net/url"
"time" "time"
"github.com/guregu/null/v6" "github.com/guregu/null/v6"
@ -32,8 +34,9 @@ type IPv6StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
} }
type NetworkConfig struct { type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
@ -71,6 +74,18 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
return listenOptions return listenOptions
} }
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
}
}
}
func (s *NetworkInterfaceState) GetHostname() string { func (s *NetworkInterfaceState) GetHostname() string {
hostname := ToValidHostname(s.config.Hostname.String) hostname := ToValidHostname(s.config.Hostname.String)

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time" "time"
) )
@ -57,6 +58,7 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
ctx, ctx,
url, url,
timeout, timeout,
t.networkConfig.GetTransportProxyFunc(),
) )
duration := time.Since(startTime) duration := time.Since(startTime)
@ -122,10 +124,16 @@ func queryHttpTime(
ctx context.Context, ctx context.Context,
url string, url string,
timeout time.Duration, timeout time.Duration,
proxyFunc func(*http.Request) (*url.URL, error),
) (now *time.Time, response *http.Response, err error) { ) (now *time.Time, response *http.Response, err error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxyFunc
client := http.Client{ client := http.Client{
Timeout: timeout, Transport: transport,
Timeout: timeout,
} }
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err

View File

@ -14,9 +14,10 @@ var keyboardConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb0"}, path: []string{"functions", "hid.usb0"},
configPath: []string{"hid.usb0"}, configPath: []string{"hid.usb0"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "1", "protocol": "1",
"subclass": "1", "subclass": "1",
"report_length": "8", "report_length": "8",
"no_out_endpoint": "0",
}, },
reportDesc: keyboardReportDesc, reportDesc: keyboardReportDesc,
} }

View File

@ -11,9 +11,10 @@ var absoluteMouseConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb1"}, path: []string{"functions", "hid.usb1"},
configPath: []string{"hid.usb1"}, configPath: []string{"hid.usb1"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "2", "protocol": "2",
"subclass": "0", "subclass": "0",
"report_length": "6", "report_length": "6",
"no_out_endpoint": "1",
}, },
reportDesc: absoluteMouseCombinedReportDesc, reportDesc: absoluteMouseCombinedReportDesc,
} }

View File

@ -11,9 +11,10 @@ var relativeMouseConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb2"}, path: []string{"functions", "hid.usb2"},
configPath: []string{"hid.usb2"}, configPath: []string{"hid.usb2"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "2", "protocol": "2",
"subclass": "1", "subclass": "1",
"report_length": "4", "report_length": "4",
"no_out_endpoint": "1",
}, },
reportDesc: relativeMouseCombinedReportDesc, reportDesc: relativeMouseCombinedReportDesc,
} }

10
ota.go
View File

@ -93,7 +93,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
return nil, fmt.Errorf("error creating request: %w", err) return nil, fmt.Errorf("error creating request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("error sending request: %w", err) return nil, fmt.Errorf("error sending request: %w", err)
} }
@ -139,6 +146,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
client := http.Client{ client := http.Client{
Timeout: 10 * time.Minute, Timeout: 10 * time.Minute,
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
TLSHandshakeTimeout: 30 * time.Second, TLSHandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
RootCAs: rootcerts.ServerCertPool(), RootCAs: rootcerts.ServerCertPool(),

View File

@ -128,6 +128,7 @@ func pressATXResetButton(duration time.Duration) error {
func mountDCControl() error { func mountDCControl() error {
_ = port.SetMode(defaultMode) _ = port.SetMode(defaultMode)
registerDCMetrics()
go runDCControl() go runDCControl()
return nil return nil
} }
@ -206,6 +207,9 @@ func runDCControl() {
dcState.Current = amps dcState.Current = amps
dcState.Power = watts dcState.Power = watts
// Update Prometheus metrics
updateDCMetrics(dcState)
if currentSession != nil { if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession) writeJSONRPCEvent("dcState", dcState, currentSession)
} }

View File

@ -67,19 +67,19 @@ function Terminal({
}) { }) {
const enableTerminal = useUiStore(state => state.terminalType == type); const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType); const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
setDisableKeyboardFocusTrap(enableTerminal); setDisableVideoFocusTrap(enableTerminal);
}, 500); }, 500);
return () => { return () => {
setDisableKeyboardFocusTrap(false); setDisableVideoFocusTrap(false);
}; };
}, [ref, instance, enableTerminal, setDisableKeyboardFocusTrap, type]); }, [enableTerminal, setDisableVideoFocusTrap]);
const readyState = dataChannel.readyState; const readyState = dataChannel.readyState;
useEffect(() => { useEffect(() => {
@ -116,7 +116,7 @@ function Terminal({
const { domEvent } = e; const { domEvent } = e;
if (domEvent.key === "Escape") { if (domEvent.key === "Escape") {
setTerminalType("none"); setTerminalType("none");
setDisableKeyboardFocusTrap(false); setDisableVideoFocusTrap(false);
domEvent.preventDefault(); domEvent.preventDefault();
} }
}); });
@ -131,7 +131,7 @@ function Terminal({
onDataHandler.dispose(); onDataHandler.dispose();
onKeyHandler.dispose(); onKeyHandler.dispose();
}; };
}, [instance, dataChannel, readyState, setDisableKeyboardFocusTrap, setTerminalType]); }, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType]);
useEffect(() => { useEffect(() => {
if (!instance) return; if (!instance) return;
@ -158,7 +158,7 @@ function Terminal({
return () => { return () => {
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [ref, instance]); }, [instance]);
return ( return (
<div <div

View File

@ -10,11 +10,11 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts"; import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
import notifications from "@/notifications"; import notifications from "@/notifications";
const hidKeyboardPayload = (keys: number[], modifier: number) => { const hidKeyboardPayload = (modifier: number, keys: number[]) => {
return { keys, modifier }; return { modifier, keys };
}; };
const modifierCode = (shift?: boolean, altRight?: boolean) => { const modifierCode = (shift?: boolean, altRight?: boolean) => {
@ -62,49 +62,56 @@ export default function PasteModal() {
const onConfirmPaste = useCallback(async () => { const onConfirmPaste = useCallback(async () => {
setPasteMode(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!safeKeyboardLayout) return; const keyboard: KeyboardLayout = selectedKeyboard(safeKeyboardLayout);
if (!chars[safeKeyboardLayout]) return; if (!keyboard) return;
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
for (const char of text) { for (const char of text) {
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char] const keyprops = keyboard.chars[char];
if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
if (!key) continue; if (!key) continue;
const keyz = [ keys[key] ]; // if this is an accented character, we need to send that accent FIRST
const modz = [ modifierCode(shift, altRight) ];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) { if (accentKey) {
keyz.unshift(keys[accentKey.key]) await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] })
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
} }
for (const [index, kei] of keyz.entries()) { // now send the actual key
await new Promise<void>((resolve, reject) => { await sendKeystroke({ modifier: modifierCode(shift, altRight), keys: [ keys[key] ]});
send(
"keyboardReport", // if what was requested was a dead key, we need to send an unmodified space to emit
hidKeyboardPayload([kei], modz[index]), // just the accent character
params => { if (deadKey) {
if ("error" in params) return reject(params.error); await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] });
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
} }
// now send a message with no keys down to "release" the keys
await sendKeystroke({ modifier: 0, keys: [] });
} }
} catch (error) { } catch (error) {
console.error(error); console.error("Failed to paste text:", error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
async function sendKeystroke(stroke: KeyStroke) {
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload(stroke.modifier, stroke.keys),
params => {
if ("error" in params) return reject(params.error);
resolve();
}
);
});
}
}, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteMode]);
useEffect(() => { useEffect(() => {
if (TextAreaRef.current) { if (TextAreaRef.current) {
@ -154,7 +161,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)] [...new Intl.Segmenter().segment(value)]
.map(x => x.segment) .map(x => x.segment)
.filter(char => !chars[safeKeyboardLayout][char]), .filter(char => !selectedKeyboard(safeKeyboardLayout).chars[char]),
), ),
]; ];
@ -175,7 +182,7 @@ export default function PasteModal() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {layouts[safeKeyboardLayout]} Sending text using keyboard layout: {selectedKeyboard(safeKeyboardLayout).name}
</p> </p>
</div> </div>
</div> </div>

View File

@ -14,7 +14,7 @@ import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() { export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]); const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
@ -24,9 +24,9 @@ export default function WakeOnLanModal() {
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null); const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => { const onCancelWakeOnLanModal = useCallback(() => {
setDisableVideoFocusTrap(false);
close(); close();
setDisableFocusTrap(false); }, [close, setDisableVideoFocusTrap]);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback( const onSendMagicPacket = useCallback(
(macAddress: string) => { (macAddress: string) => {
@ -43,12 +43,12 @@ export default function WakeOnLanModal() {
} }
} else { } else {
notifications.success("Magic Packet sent successfully"); notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false); setDisableVideoFocusTrap(false);
close(); close();
} }
}); });
}, },
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap], [close, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap],
); );
const syncStoredDevices = useCallback(() => { const syncStoredDevices = useCallback(() => {
@ -78,7 +78,7 @@ export default function WakeOnLanModal() {
} }
}); });
}, },
[storedDevices, send, syncStoredDevices], [send, storedDevices, syncStoredDevices],
); );
const onAddDevice = useCallback( const onAddDevice = useCallback(

View File

@ -747,6 +747,7 @@ export type TimeSyncMode =
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
domain: string; domain: string;
http_proxy: string;
ipv4_mode: IPv4Mode; ipv4_mode: IPv4Mode;
ipv6_mode: IPv6Mode; ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode; lldp_mode: LLDPMode;
@ -935,5 +936,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
}, }
})); }));

View File

@ -1,45 +1,32 @@
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE" export interface KeyStroke { modifier: number; keys: number[]; }
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ" export interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK" export interface KeyCombo extends KeyInfo { deadKey?: boolean, accentKey?: KeyInfo }
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US" export interface KeyboardLayout { isoCode: string, name: string, chars: Record<string, KeyCombo> }
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean } // to add a new layout, create a file like the above and add it to the list
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo } import { cs_CZ } from "@/keyboardLayouts/cs_CZ"
import { de_CH } from "@/keyboardLayouts/de_CH"
import { de_DE } from "@/keyboardLayouts/de_DE"
import { en_US } from "@/keyboardLayouts/en_US"
import { en_UK } from "@/keyboardLayouts/en_UK"
import { es_ES } from "@/keyboardLayouts/es_ES"
import { fr_BE } from "@/keyboardLayouts/fr_BE"
import { fr_CH } from "@/keyboardLayouts/fr_CH"
import { fr_FR } from "@/keyboardLayouts/fr_FR"
import { it_IT } from "@/keyboardLayouts/it_IT"
import { nb_NO } from "@/keyboardLayouts/nb_NO"
import { sv_SE } from "@/keyboardLayouts/sv_SE"
export const layouts: Record<string, string> = { export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ];
be_FR: name_fr_BE,
cs_CZ: name_cs_CZ,
en_UK: name_en_UK,
en_US: name_en_US,
fr_FR: name_fr_FR,
de_DE: name_de_DE,
it_IT: name_it_IT,
nb_NO: name_nb_NO,
es_ES: name_es_ES,
sv_SE: name_sv_SE,
fr_CH: name_fr_CH,
de_CH: name_de_CH,
}
export const chars: Record<string, Record<string, KeyCombo>> = { export const selectedKeyboard = (isoCode: string): KeyboardLayout => {
be_FR: chars_fr_BE, // fallback to original behaviour of en-US if no isoCode given
cs_CZ: chars_cs_CZ, return keyboards.find(keyboard => keyboard.isoCode == isoCode)
en_UK: chars_en_UK, ?? keyboards.find(keyboard => keyboard.isoCode == "en-US")!;
en_US: chars_en_US,
fr_FR: chars_fr_FR,
de_DE: chars_de_DE,
it_IT: chars_it_IT,
nb_NO: chars_nb_NO,
es_ES: chars_es_ES,
sv_SE: chars_sv_SE,
fr_CH: chars_fr_CH,
de_CH: chars_de_CH,
}; };
export const keyboardOptions = () => {
return keyboards.map((keyboard) => {
return { label: keyboard.name, value: keyboard.isoCode }
});
}

View File

@ -1,6 +1,6 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Čeština"; const name = "Čeština";
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
@ -13,7 +13,7 @@ const keyOverdot = { key: "Digit8", shift: true, altRight: true } // overdot (do
const keyHook = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter const keyHook = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
const keyCedille = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter const keyCedille = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -242,3 +242,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const cs_CZ: KeyboardLayout = {
isoCode: "cs-CZ",
name: name,
chars: chars
};

View File

@ -1,6 +1,6 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Schwiizerdütsch"; const name = "Schwiizerdütsch";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
@ -8,7 +8,7 @@ const keyHat = { key: "Equal" } // accent circonflexe (accent hat), mark ^ place
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter const keyTilde = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -163,3 +163,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const de_CH: KeyboardLayout = {
isoCode: "de-CH",
name: name,
chars: chars
};

View File

@ -1,12 +1,12 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Deutsch"; const name = "Deutsch";
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat }, "Â": { key: "KeyA", shift: true, accentKey: keyHat },
@ -150,3 +150,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const de_DE: KeyboardLayout = {
isoCode: "de-DE",
name: name,
chars: chars
};

View File

@ -1,8 +1,8 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "English (UK)"; const name = "English (UK)";
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true }, B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true }, C: { key: "KeyC", shift: true },
@ -105,3 +105,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo> } as Record<string, KeyCombo>
export const en_UK: KeyboardLayout = {
isoCode: "en-UK",
name: name,
chars: chars
};

View File

@ -1,8 +1,8 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "English (US)"; const name = "English (US)";
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true }, B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true }, C: { key: "KeyC", shift: true },
@ -111,3 +111,9 @@ export const chars = {
Insert: { key: "Insert", shift: false }, Insert: { key: "Insert", shift: false },
Delete: { key: "Delete", shift: false }, Delete: { key: "Delete", shift: false },
} as Record<string, KeyCombo> } as Record<string, KeyCombo>
export const en_US: KeyboardLayout = {
isoCode: "en-US",
name: name,
chars: chars
};

View File

@ -1,6 +1,6 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Español"; const name = "Español";
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
@ -8,7 +8,7 @@ const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accen
const keyGrave = { key: "BracketRight" } // accent grave, mark ` placed above the letter const keyGrave = { key: "BracketRight" } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter const keyTilde = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -166,3 +166,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const es_ES: KeyboardLayout = {
isoCode: "es-ES",
name: name,
chars: chars
};

View File

@ -1,6 +1,6 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Belgisch Nederlands"; const name = "Belgisch Nederlands";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
@ -8,7 +8,7 @@ const keyAcute = { key: "Semicolon", altRight: true } // accent aigu (acute acce
const keyGrave = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter const keyGrave = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter const keyTilde = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const chars = {
A: { key: "KeyQ", shift: true }, A: { key: "KeyQ", shift: true },
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
"Â": { key: "KeyQ", shift: true, accentKey: keyHat }, "Â": { key: "KeyQ", shift: true, accentKey: keyHat },
@ -165,3 +165,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const fr_BE: KeyboardLayout = {
isoCode: "fr-BE",
name: name,
chars: chars
};

View File

@ -1,11 +1,11 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { chars as chars_de_CH } from "./de_CH" import { de_CH } from "./de_CH"
export const name = "Français de Suisse"; const name = "Français de Suisse";
export const chars = { const chars = {
...chars_de_CH, ...de_CH.chars,
"è": { key: "BracketLeft" }, "è": { key: "BracketLeft" },
"ü": { key: "BracketLeft", shift: true }, "ü": { key: "BracketLeft", shift: true },
"é": { key: "Semicolon" }, "é": { key: "Semicolon" },
@ -13,3 +13,9 @@ export const chars = {
"à": { key: "Quote" }, "à": { key: "Quote" },
"ä": { key: "Quote", shift: true }, "ä": { key: "Quote", shift: true },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const fr_CH: KeyboardLayout = {
isoCode: "fr-CH",
name: name,
chars: chars
};

View File

@ -1,11 +1,11 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Français"; const name = "Français";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
export const chars = { const chars = {
A: { key: "KeyQ", shift: true }, A: { key: "KeyQ", shift: true },
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
"Â": { key: "KeyQ", shift: true, accentKey: keyHat }, "Â": { key: "KeyQ", shift: true, accentKey: keyHat },
@ -137,3 +137,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const fr_FR: KeyboardLayout = {
isoCode: "fr-FR",
name: name,
chars: chars
};

View File

@ -1,8 +1,8 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Italiano"; const name = "Italiano";
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true }, B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true }, C: { key: "KeyC", shift: true },
@ -111,3 +111,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const it_IT: KeyboardLayout = {
isoCode: "it-IT",
name: name,
chars: chars
};

View File

@ -1,6 +1,6 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Norsk bokmål"; const name = "Norsk bokmål";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
@ -8,7 +8,7 @@ const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accen
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -165,3 +165,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const nb_NO: KeyboardLayout = {
isoCode: "nb-NO",
name: name,
chars: chars
};

View File

@ -1,6 +1,6 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Svenska"; const name = "Svenska";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
@ -8,7 +8,7 @@ const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accen
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat }, "Â": { key: "KeyA", shift: true, accentKey: keyHat },
@ -162,3 +162,9 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const sv_SE: KeyboardLayout = {
isoCode: "sv-SE",
name: name,
chars: chars
};

View File

@ -1,17 +1,19 @@
// Key codes and modifiers correspond to definitions in the // Key codes and modifiers correspond to definitions in the
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt) // [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
// [Section 10. Keyboard/Keypad Page 0x07](https://usb.org/sites/default/files/hut1_21.pdf)
export const keys = { export const keys = {
ArrowDown: 0x51, ArrowDown: 0x51,
ArrowLeft: 0x50, ArrowLeft: 0x50,
ArrowRight: 0x4f, ArrowRight: 0x4f,
ArrowUp: 0x52, ArrowUp: 0x52,
Backquote: 0x35, Backquote: 0x35, // aka Grave
Backslash: 0x31, Backslash: 0x31,
Backspace: 0x2a, Backspace: 0x2a,
BracketLeft: 0x2f, BracketLeft: 0x2f, // aka LeftBrace
BracketRight: 0x30, BracketRight: 0x30, // aka RightBrace
CapsLock: 0x39, CapsLock: 0x39,
Comma: 0x36, Comma: 0x36,
Compose: 0x65,
ContextMenu: 0, ContextMenu: 0,
Delete: 0x4c, Delete: 0x4c,
Digit0: 0x27, Digit0: 0x27,
@ -40,10 +42,21 @@ export const keys = {
F10: 0x43, F10: 0x43,
F11: 0x44, F11: 0x44,
F12: 0x45, F12: 0x45,
F13: 0x68, F14: 0x69,
F15: 0x6a,
F16: 0x6b,
F17: 0x6c,
F18: 0x6d,
F19: 0x6e,
F20: 0x6f,
F21: 0x70,
F22: 0x71,
F23: 0x72,
F24: 0x73,
Home: 0x4a, Home: 0x4a,
HashTilde: 0x32, // non-US # and ~
Insert: 0x49, Insert: 0x49,
IntlBackslash: 0x64, IntlBackslash: 0x64, // non-US \ and |
KeyA: 0x04, KeyA: 0x04,
KeyB: 0x05, KeyB: 0x05,
KeyC: 0x06, KeyC: 0x06,
@ -72,30 +85,35 @@ export const keys = {
KeyZ: 0x1d, KeyZ: 0x1d,
KeypadExclamation: 0xcf, KeypadExclamation: 0xcf,
Minus: 0x2d, Minus: 0x2d,
NumLock: 0x53, None: 0x00,
Numpad0: 0x62, NumLock: 0x53, // and Clear
Numpad1: 0x59, Numpad0: 0x62, // and Insert
Numpad2: 0x5a, Numpad1: 0x59, // and End
Numpad3: 0x5b, Numpad2: 0x5a, // and Down Arrow
Numpad4: 0x5c, Numpad3: 0x5b, // and Page Down
Numpad4: 0x5c, // and Left Arrow
Numpad5: 0x5d, Numpad5: 0x5d,
Numpad6: 0x5e, Numpad6: 0x5e, // and Right Arrow
Numpad7: 0x5f, Numpad7: 0x5f, // and Home
Numpad8: 0x60, Numpad8: 0x60, // and Up Arrow
Numpad9: 0x61, Numpad9: 0x61, // and Page Up
NumpadAdd: 0x57, NumpadAdd: 0x57,
NumpadComma: 0x85,
NumpadDecimal: 0x63,
NumpadDivide: 0x54, NumpadDivide: 0x54,
NumpadEnter: 0x58, NumpadEnter: 0x58,
NumpadEqual: 0x67, NumpadEqual: 0x67,
NumpadLeftParen: 0xb6,
NumpadMultiply: 0x55, NumpadMultiply: 0x55,
NumpadRightParen: 0xb7,
NumpadSubtract: 0x56, NumpadSubtract: 0x56,
NumpadDecimal: 0x63,
PageDown: 0x4e, PageDown: 0x4e,
PageUp: 0x4b, PageUp: 0x4b,
Period: 0x37, Period: 0x37,
PrintScreen: 0x46, PrintScreen: 0x46,
Pause: 0x48, Pause: 0x48,
Quote: 0x34, Power: 0x66,
Quote: 0x34, // aka Single Quote or Apostrophe
ScrollLock: 0x47, ScrollLock: 0x47,
Semicolon: 0x33, Semicolon: 0x33,
Slash: 0x38, Slash: 0x38,

View File

@ -4,7 +4,7 @@ import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { layouts } from "@/keyboardLayouts"; import { keyboardOptions } from "@/keyboardLayouts";
import { Checkbox } from "@/components/Checkbox"; import { Checkbox } from "@/components/Checkbox";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
@ -32,7 +32,7 @@ export default function SettingsKeyboardRoute() {
return "en_US"; return "en_US";
}, [keyboardLayout]); }, [keyboardLayout]);
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } }) const layoutOptions = keyboardOptions();
const ledSyncOptions = [ const ledSyncOptions = [
{ value: "auto", label: "Automatic" }, { value: "auto", label: "Automatic" },
{ value: "browser", label: "Browser Only" }, { value: "browser", label: "Browser Only" },
@ -46,7 +46,7 @@ export default function SettingsKeyboardRoute() {
if ("error" in resp) return; if ("error" in resp) return;
setKeyboardLayout(resp.result as string); setKeyboardLayout(resp.result as string);
}); });
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [send, setKeyboardLayout]);
const onKeyboardLayoutChange = useCallback( const onKeyboardLayoutChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => { (e: React.ChangeEvent<HTMLSelectElement>) => {

View File

@ -34,6 +34,7 @@ dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = { const defaultNetworkSettings: NetworkSettings = {
hostname: "", hostname: "",
http_proxy: "",
domain: "", domain: "",
ipv4_mode: "unknown", ipv4_mode: "unknown",
ipv6_mode: "unknown", ipv6_mode: "unknown",
@ -185,6 +186,10 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, hostname: value }); setNetworkSettings({ ...networkSettings, hostname: value });
}; };
const handleProxyChange = (value: string) => {
setNetworkSettings({ ...networkSettings, http_proxy: value });
};
const handleDomainChange = (value: string) => { const handleDomainChange = (value: string) => {
setNetworkSettings({ ...networkSettings, domain: value }); setNetworkSettings({ ...networkSettings, domain: value });
}; };
@ -253,6 +258,26 @@ export default function SettingsNetworkRoute() {
</div> </div>
</SettingsItem> </SettingsItem>
</div> </div>
<div className="space-y-4">
<SettingsItem
title="HTTP Proxy"
description="Proxy server for outgoing HTTP(S) requests from the device. Blank for none."
>
<div className="relative">
<div>
<InputField
size="SM"
type="text"
placeholder="http://proxy.example.com:8080/"
defaultValue={networkSettings.http_proxy}
onChange={e => {
handleProxyChange(e.target.value);
}}
/>
</div>
</div>
</SettingsItem>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">

View File

@ -79,7 +79,7 @@ export default function SettingsRoute() {
return () => { return () => {
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
}; };
}, [setDisableVideoFocusTrap, sendKeyboardEvent]); }, [sendKeyboardEvent, setDisableVideoFocusTrap]);
return ( return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white"> <div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
@ -151,7 +151,6 @@ export default function SettingsRoute() {
className={({ isActive }) => (isActive ? "active" : "")} className={({ isActive }) => (isActive ? "active" : "")}
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuMouse className="h-4 w-4 shrink-0" /> <LuMouse className="h-4 w-4 shrink-0" />
<h1>Mouse</h1> <h1>Mouse</h1>
</div> </div>
@ -163,7 +162,7 @@ export default function SettingsRoute() {
to="keyboard" to="keyboard"
className={({ isActive }) => (isActive ? "active" : "")} className={({ isActive }) => (isActive ? "active" : "")}
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuKeyboard className="h-4 w-4 shrink-0" /> <LuKeyboard className="h-4 w-4 shrink-0" />
<h1>Keyboard</h1> <h1>Keyboard</h1>
</div> </div>

View File

@ -707,7 +707,7 @@ export default function KvmIdRoute() {
}, [diskChannel, file]); }, [diskChannel, file]);
// System update // System update
const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap); const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null); const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null);
const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null); const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null);
@ -805,7 +805,7 @@ export default function KvmIdRoute() {
)} )}
<div className="relative h-full"> <div className="relative h-full">
<FocusTrap <FocusTrap
paused={disableKeyboardFocusTrap} paused={disableVideoFocusTrap}
focusTrapOptions={{ focusTrapOptions={{
allowOutsideClick: true, allowOutsideClick: true,
escapeDeactivates: false, escapeDeactivates: false,

22
wol.go
View File

@ -4,6 +4,24 @@ import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"net" "net"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
wolPackets = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_wol_sent_packets_total",
Help: "Total number of Wake-on-LAN magic packets sent.",
},
)
wolErrors = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_wol_sent_packet_errors_total",
Help: "Total number of Wake-on-LAN magic packets errors.",
},
)
) )
// SendWOLMagicPacket sends a Wake-on-LAN magic packet to the specified MAC address // SendWOLMagicPacket sends a Wake-on-LAN magic packet to the specified MAC address
@ -11,6 +29,7 @@ func rpcSendWOLMagicPacket(macAddress string) error {
// Parse the MAC address // Parse the MAC address
mac, err := net.ParseMAC(macAddress) mac, err := net.ParseMAC(macAddress)
if err != nil { if err != nil {
wolErrors.Inc()
return ErrorfL(wolLogger, "invalid MAC address", err) return ErrorfL(wolLogger, "invalid MAC address", err)
} }
@ -20,6 +39,7 @@ func rpcSendWOLMagicPacket(macAddress string) error {
// Set up UDP connection // Set up UDP connection
conn, err := net.Dial("udp", "255.255.255.255:9") conn, err := net.Dial("udp", "255.255.255.255:9")
if err != nil { if err != nil {
wolErrors.Inc()
return ErrorfL(wolLogger, "failed to establish UDP connection", err) return ErrorfL(wolLogger, "failed to establish UDP connection", err)
} }
defer conn.Close() defer conn.Close()
@ -27,10 +47,12 @@ func rpcSendWOLMagicPacket(macAddress string) error {
// Send the packet // Send the packet
_, err = conn.Write(packet) _, err = conn.Write(packet)
if err != nil { if err != nil {
wolErrors.Inc()
return ErrorfL(wolLogger, "failed to send WOL packet", err) return ErrorfL(wolLogger, "failed to send WOL packet", err)
} }
wolLogger.Info().Str("mac", macAddress).Msg("WOL packet sent") wolLogger.Info().Str("mac", macAddress).Msg("WOL packet sent")
wolPackets.Inc()
return nil return nil
} }