Update App version to 0.0.4

Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
luckfox-eng29
2025-11-11 20:38:22 +08:00
parent 4e82b8a11c
commit 5e17c52afc
41 changed files with 3537 additions and 598 deletions

View File

@@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV ?= 0.0.3-dev
VERSION ?= 0.0.3
VERSION_DEV ?= 0.0.4-dev
VERSION ?= 0.0.4
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := kvm

View File

@@ -92,6 +92,7 @@ type Config struct {
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
EdidString string `json:"hdmi_edid_string"`
ForceHpd bool `json:"force_hpd"` // 强制输出EDID
ActiveExtension string `json:"active_extension"`
DisplayRotation string `json:"display_rotation"`
DisplayMaxBrightness int `json:"display_max_brightness"`
@@ -101,6 +102,7 @@ type Config struct {
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
AppliedNetworkConfig *network.NetworkConfig `json:"applied_network_config,omitempty"`
DefaultLogLevel string `json:"default_log_level"`
TailScaleAutoStart bool `json:"tailscale_autostart"`
TailScaleXEdge bool `json:"tailscale_xedge"`
@@ -108,6 +110,8 @@ type Config struct {
ZeroTierAutoStart bool `json:"zerotier_autostart"`
FrpcAutoStart bool `json:"frpc_autostart"`
FrpcToml string `json:"frpc_toml"`
CloudflaredAutoStart bool `json:"cloudflared_autostart"`
CloudflaredToken string `json:"cloudflared_token"`
IO0Status bool `json:"io0_status"`
IO1Status bool `json:"io1_status"`
AudioMode string `json:"audio_mode"`
@@ -117,6 +121,19 @@ type Config struct {
AutoMountSystemInfo bool `json:"auto_mount_system_info_img"`
EasytierAutoStart bool `json:"easytier_autostart"`
EasytierConfig EasytierConfig `json:"easytier_config"`
VntAutoStart bool `json:"vnt_autostart"`
VntConfig VntConfig `json:"vnt_config"`
}
type VntConfig struct {
Token string `json:"token"`
DeviceId string `json:"device_id"`
Name string `json:"name"`
ServerAddr string `json:"server_addr"`
ConfigMode string `json:"config_mode"` // "params" or "file"
ConfigFile string `json:"config_file"`
Model string `json:"model"`
Password string `json:"password"`
}
const configPath = "/userdata/kvm_config.json"
@@ -134,6 +151,7 @@ var defaultConfig = &Config{
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
TLSMode: "",
ForceHpd: false, // 默认不强制输出EDID
UsbConfig: &usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
@@ -150,11 +168,13 @@ var defaultConfig = &Config{
Mtp: false,
},
NetworkConfig: &network.NetworkConfig{},
AppliedNetworkConfig: nil,
DefaultLogLevel: "INFO",
ZeroTierAutoStart: false,
TailScaleAutoStart: false,
TailScaleXEdge: false,
FrpcAutoStart: false,
CloudflaredAutoStart: false,
IO0Status: true,
IO1Status: true,
AudioMode: "disabled",

View File

@@ -345,15 +345,35 @@ func (f *FieldConfig) validateField() error {
return nil
}
switch v := f.CurrentValue.(type) {
case []string:
// Validate each element in the slice
for _, item := range v {
if item == "" {
continue
}
if err := f.validateScalarValue(item); err != nil {
return err
}
}
default:
val, err := toString(f.CurrentValue)
if err != nil {
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
}
if val == "" {
return nil
}
if err := f.validateScalarValue(val); err != nil {
return err
}
}
return nil
}
// validateScalarValue applies ValidateTypes to a single string value.
func (f *FieldConfig) validateScalarValue(val string) error {
for _, validateType := range f.ValidateTypes {
switch validateType {
case "ipv4":
@@ -376,6 +396,5 @@ func (f *FieldConfig) validateField() error {
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
}
}
return nil
}

View File

@@ -22,6 +22,22 @@ func toString(v interface{}) (string, error) {
return v, nil
case null.String:
return v.String, nil
case []string:
if len(v) == 0 {
return "", nil
}
if len(v) == 1 {
return v[0], nil
}
return strings.Join(v, ","), nil
case []interface{}:
if len(v) == 0 {
return "", nil
}
if s, ok := v[0].(string); ok {
return s, nil
}
return "", fmt.Errorf("unsupported type in slice: %T", v[0])
}
return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v))

View File

@@ -50,6 +50,7 @@ type NetworkConfig struct {
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
}
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {

View File

@@ -3,6 +3,9 @@ package network
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"kvm/internal/confparser"
@@ -100,6 +103,31 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
s.dhcpClient = dhcpClient
mode := strings.TrimSpace(s.config.IPv4Mode.String)
switch mode {
case "static":
if s.dhcpClient != nil {
s.dhcpClient.SetEnabled(false)
}
if err := s.applyIPv4Static(); err != nil {
l.Error().Err(err).Msg("failed to apply static IPv4 on init")
}
case "dhcp":
if s.dhcpClient != nil {
s.dhcpClient.SetEnabled(true)
}
case "disabled":
if s.dhcpClient != nil {
s.dhcpClient.SetEnabled(false)
}
if err := s.clearIPv4Addresses(); err != nil {
l.Warn().Err(err).Msg("failed to clear IPv4 addresses on init")
}
if err := s.clearDefaultIPv4Route(); err != nil {
l.Debug().Err(err).Msg("failed to clear default route on init")
}
}
return s, nil
}
@@ -346,3 +374,159 @@ func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
_ = s.setHostnameIfNotSame()
s.cbConfigChange(config)
}
// clearIPv4Addresses removes all IPv4 addresses from the interface.
func (s *NetworkInterfaceState) clearIPv4Addresses() error {
iface, err := netlink.LinkByName(s.interfaceName)
if err != nil {
return err
}
addrs, err := netlinkAddrs(iface)
if err != nil {
return err
}
for _, addr := range addrs {
if addr.IP.To4() != nil {
if err := netlink.AddrDel(iface, &addr); err != nil {
s.l.Warn().Err(err).Str("addr", addr.IPNet.String()).Msg("failed to delete IPv4 address")
}
}
}
return nil
}
// clearDefaultIPv4Route removes existing default IPv4 route on this interface.
func (s *NetworkInterfaceState) clearDefaultIPv4Route() error {
iface, err := netlink.LinkByName(s.interfaceName)
if err != nil {
return err
}
routes, err := netlink.RouteList(iface, netlink.FAMILY_V4)
if err != nil {
return err
}
for _, r := range routes {
if r.Dst == nil { // default route
if err := netlink.RouteDel(&r); err != nil {
s.l.Warn().Err(err).Msg("failed to delete default route")
}
}
}
return nil
}
// parseIPv4Mask converts dotted decimal netmask to net.IPMask.
func parseIPv4Mask(mask string) (net.IPMask, error) {
parts := strings.Split(strings.TrimSpace(mask), ".")
if len(parts) != 4 {
return nil, fmt.Errorf("invalid netmask: %s", mask)
}
bytes := make([]byte, 4)
for i := 0; i < 4; i++ {
v, err := strconv.Atoi(parts[i])
if err != nil || v < 0 || v > 255 {
return nil, fmt.Errorf("invalid netmask octet: %s", parts[i])
}
bytes[i] = byte(v)
}
return net.IPv4Mask(bytes[0], bytes[1], bytes[2], bytes[3]), nil
}
// writeResolvConf writes DNS servers and optional domain into /etc/resolv.conf.
func (s *NetworkInterfaceState) writeResolvConf(dns []string, domain string) error {
var b strings.Builder
if domain != "" {
b.WriteString("search ")
b.WriteString(domain)
b.WriteString("\n")
}
for _, d := range dns {
d = strings.TrimSpace(d)
if d == "" {
continue
}
b.WriteString("nameserver ")
b.WriteString(d)
b.WriteString("\n")
}
content := b.String()
if content == "" {
return nil
}
if err := os.WriteFile("/etc/resolv.conf", []byte(content), 0644); err != nil {
return err
}
s.l.Info().Msg("updated /etc/resolv.conf for static IPv4")
return nil
}
// applyIPv4Static sets a static IPv4 address, default route, and DNS.
func (s *NetworkInterfaceState) applyIPv4Static() error {
if s.config == nil || s.config.IPv4Static == nil {
return fmt.Errorf("IPv4Static config not provided")
}
ipStr := strings.TrimSpace(s.config.IPv4Static.Address.String)
maskStr := strings.TrimSpace(s.config.IPv4Static.Netmask.String)
gwStr := strings.TrimSpace(s.config.IPv4Static.Gateway.String)
dns := s.config.IPv4Static.DNS
ip := net.ParseIP(ipStr)
if ip == nil || ip.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %s", ipStr)
}
mask, err := parseIPv4Mask(maskStr)
if err != nil {
return err
}
iface, err := netlink.LinkByName(s.interfaceName)
if err != nil {
return err
}
// Clear existing IPv4 addresses and default route
if err := s.clearIPv4Addresses(); err != nil {
s.l.Warn().Err(err).Msg("failed clearing IPv4 addresses prior to static apply")
}
if err := s.clearDefaultIPv4Route(); err != nil {
s.l.Warn().Err(err).Msg("failed clearing default route prior to static apply")
}
ipNet := &net.IPNet{IP: ip, Mask: mask}
addr := &netlink.Addr{IPNet: ipNet}
if err := netlink.AddrAdd(iface, addr); err != nil {
return logging.ErrorfL(s.l, "failed to add static IPv4 address", err)
}
s.l.Info().Str("ipv4", ipNet.String()).Msg("static IPv4 address applied")
// Default route
if gwStr != "" {
gw := net.ParseIP(gwStr)
if gw == nil || gw.To4() == nil {
s.l.Warn().Str("gateway", gwStr).Msg("invalid IPv4 gateway; skipping route")
} else {
route := netlink.Route{LinkIndex: iface.Attrs().Index, Gw: gw}
// remove any existing default routes already attempted above, then add
if err := netlink.RouteAdd(&route); err != nil {
// try replace if add failed
if replaceErr := netlink.RouteReplace(&route); replaceErr != nil {
s.l.Warn().Err(err).Msg("failed to add default route")
}
}
s.l.Info().Str("gateway", gwStr).Msg("default route applied")
}
}
// DNS
if len(dns) > 0 {
if err := s.writeResolvConf(dns, s.GetDomain()); err != nil {
s.l.Warn().Err(err).Msg("failed to write resolv.conf")
}
}
// Refresh internal state
if _, err := s.update(); err != nil {
s.l.Warn().Err(err).Msg("failed to refresh state after static apply")
}
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"kvm/internal/confparser"
"kvm/internal/udhcpc"
"github.com/guregu/null/v6"
)
type RpcIPv6Address struct {
@@ -106,7 +108,12 @@ func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSetting
return err
}
if IsSame(currentSettings, settings.NetworkConfig) {
neutralA := *currentSettings
neutralB := settings.NetworkConfig
neutralA.PendingReboot = null.Bool{}
neutralB.PendingReboot = null.Bool{}
if IsSame(neutralA, neutralB) {
// no changes, do nothing
return nil
}
@@ -124,3 +131,10 @@ func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
return s.dhcpClient.Renew()
}
func (s *NetworkInterfaceState) RpcRequestDHCPAddress(ip string) error {
if s.dhcpClient == nil {
return fmt.Errorf("dhcp client not initialized")
}
return s.dhcpClient.RequestAddress(ip)
}

View File

@@ -32,6 +32,7 @@ type DHCPClient struct {
logger *zerolog.Logger
process *os.Process
onLeaseChange func(lease *Lease)
enabled bool
}
type DHCPClientOptions struct {
@@ -57,6 +58,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
pidFile: options.PidFile,
onLeaseChange: options.OnLeaseChange,
requestAddress: options.RequestAddress,
enabled: true,
}
}
@@ -87,8 +89,12 @@ func (c *DHCPClient) watchLink() {
for update := range ch {
if update.Link.Attrs().Name == c.InterfaceName {
if update.Flags&unix.IFF_RUNNING != 0 {
if c.enabled {
c.logger.Info().Msg("link is up, starting udhcpc")
go c.runUDHCPC()
} else {
c.logger.Debug().Msg("link is up, DHCP disabled")
}
} else {
c.logger.Info().Msg("link is down")
}
@@ -110,6 +116,10 @@ func (w *udhcpcOutput) Write(p []byte) (n int, err error) {
}
func (c *DHCPClient) runUDHCPC() {
if !c.enabled {
c.logger.Debug().Msg("DHCP disabled; skipping udhcpc start")
return
}
cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1")
if c.requestAddress != "" {
ip := net.ParseIP(c.requestAddress)
@@ -273,3 +283,29 @@ func (c *DHCPClient) loadLeaseFile() error {
func (c *DHCPClient) GetLease() *Lease {
return c.lease
}
// RequestAddress updates the requested IPv4 address and restarts udhcpc with -r <ip>.
func (c *DHCPClient) RequestAddress(ip string) error {
parsed := net.ParseIP(ip)
if parsed == nil || parsed.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %s", ip)
}
c.requestAddress = ip
_ = c.KillProcess()
go c.runUDHCPC()
return nil
}
// SetEnabled toggles DHCP client behavior. When enabling, it will attempt to start udhcpc.
// When disabling, it kills any running udhcpc process.
func (c *DHCPClient) SetEnabled(enable bool) {
if c.enabled == enable {
return
}
c.enabled = enable
if enable {
go c.runUDHCPC()
} else {
_ = c.KillProcess()
}
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/prometheus/procfs"
@@ -406,33 +407,79 @@ func (c *ChangeSet) ApplyChanges() error {
}
func (c *ChangeSet) applyChange(change *FileChange) error {
// 记录操作详情
contentPreview := ""
if len(change.ExpectedContent) > 0 && len(change.ExpectedContent) <= 64 {
contentPreview = string(change.ExpectedContent)
} else if len(change.ExpectedContent) > 64 {
contentPreview = string(change.ExpectedContent[:64]) + "..."
}
defaultLogger.Debug().
Str("operation", FileChangeResolvedActionString[change.Action()]).
Str("path", change.Path).
Str("content_preview", contentPreview).
Int("content_length", len(change.ExpectedContent)).
Msg("executing file operation")
switch change.Action() {
case FileChangeResolvedActionWriteFile:
defaultLogger.Debug().Str("path", change.Path).Msg("writing file")
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionUpdateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
defaultLogger.Debug().Str("path", change.Path).Msg("updating file")
err := os.WriteFile(change.Path, change.ExpectedContent, 0644)
if err != nil && strings.Contains(err.Error(), "device or resource busy") {
defaultLogger.Error().
Str("path", change.Path).
Str("content", contentPreview).
Msg("device or resource busy - gadget may be bound to UDC")
return fmt.Errorf("%w (hint: gadget may be bound to UDC, try unbinding first)", err)
}
return err
case FileChangeResolvedActionCreateFile:
defaultLogger.Debug().Str("path", change.Path).Msg("creating file")
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateSymlink:
return os.Symlink(string(change.ExpectedContent), change.Path)
target := string(change.ExpectedContent)
defaultLogger.Debug().
Str("path", change.Path).
Str("target", target).
Msg("creating symlink")
return os.Symlink(target, change.Path)
case FileChangeResolvedActionRecreateSymlink:
target := string(change.ExpectedContent)
defaultLogger.Debug().
Str("path", change.Path).
Str("target", target).
Msg("recreating symlink")
if err := os.Remove(change.Path); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
return os.Symlink(string(change.ExpectedContent), change.Path)
return os.Symlink(target, change.Path)
case FileChangeResolvedActionReorderSymlinks:
defaultLogger.Debug().
Str("path", change.Path).
Int("symlink_count", len(change.ParamSymlinks)).
Msg("reordering symlinks")
return recreateSymlinks(change, nil)
case FileChangeResolvedActionCreateDirectory:
defaultLogger.Debug().Str("path", change.Path).Msg("creating directory")
return os.MkdirAll(change.Path, 0755)
case FileChangeResolvedActionRemove:
defaultLogger.Debug().Str("path", change.Path).Msg("removing file")
return os.Remove(change.Path)
case FileChangeResolvedActionRemoveDirectory:
defaultLogger.Debug().Str("path", change.Path).Msg("removing directory")
return os.RemoveAll(change.Path)
case FileChangeResolvedActionTouch:
defaultLogger.Debug().Str("path", change.Path).Msg("touching file")
return os.Chtimes(change.Path, time.Now(), time.Now())
case FileChangeResolvedActionMountConfigFS:
defaultLogger.Debug().Str("path", change.Path).Msg("mounting configfs")
return mountConfigFS(change.Path)
case FileChangeResolvedActionMountFunctionFS:
defaultLogger.Debug().Str("path", change.Path).Msg("mounting functionfs")
return mountFunctionFS(change.Path)
case FileChangeResolvedActionDoNothing:
return nil

View File

@@ -5,7 +5,9 @@ import (
"fmt"
"os"
"os/exec"
"path"
"strings"
"time"
)
type gadgetConfigItem struct {
@@ -238,19 +240,73 @@ func (u *UsbGadget) Init() error {
udcs := getUdcs()
if len(udcs) < 1 {
u.log.Warn().Msg("no UDC found, skipping USB stack init")
return u.logWarn("no udc found, skipping USB stack init", nil)
}
u.udc = udcs[0]
if err := u.ensureGadgetUnbound(); err != nil {
u.log.Warn().Err(err).Msg("failed to ensure gadget is unbound, will continue")
} else {
u.log.Info().Msg("gadget unbind check completed")
}
err := u.configureUsbGadget(false)
if err != nil {
u.log.Error().Err(err).
Str("udc", u.udc).
Interface("enabled_devices", u.enabledDevices).
Msg("USB gadget initialization FAILED")
return u.logError("unable to initialize USB stack", err)
}
return nil
}
func (u *UsbGadget) ensureGadgetUnbound() error {
udcPath := path.Join(u.kvmGadgetPath, "UDC")
if _, err := os.Stat(u.kvmGadgetPath); os.IsNotExist(err) {
return nil
}
udcContent, err := os.ReadFile(udcPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read UDC file: %w", err)
}
currentUDC := strings.TrimSpace(string(udcContent))
if currentUDC == "" || currentUDC == "none" {
return nil
}
u.log.Info().
Str("current_udc", currentUDC).
Str("target_udc", u.udc).
Msg("unbinding existing UDC before reconfiguration")
if err := u.UnbindUDC(); err != nil {
u.log.Warn().Err(err).Msg("failed to unbind via UDC file, trying DWC3")
if err := u.UnbindUDCToDWC3(); err != nil {
return fmt.Errorf("failed to unbind UDC: %w", err)
}
}
time.Sleep(200 * time.Millisecond)
if content, err := os.ReadFile(udcPath); err == nil {
if strings.TrimSpace(string(content)) != "none" {
u.log.Warn().Msg("UDC still bound after unbind attempt")
}
}
return nil
}
func (u *UsbGadget) UpdateGadgetConfig() error {
u.configLock.Lock()
defer u.configLock.Unlock()
@@ -266,13 +322,48 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
}
func (u *UsbGadget) configureUsbGadget(resetUsb bool) error {
u.log.Info().
Bool("reset_usb", resetUsb).
Msg("configuring USB gadget via transaction")
return u.WithTransaction(func() error {
u.log.Info().Msg("Transaction: Mounting configfs")
u.tx.MountConfigFS()
u.log.Info().Msg("Transaction: Creating config path")
u.tx.CreateConfigPath()
u.log.Info().Msg("Transaction: Writing gadget configuration")
u.tx.WriteGadgetConfig()
if resetUsb {
u.log.Info().Msg("Transaction: Rebinding USB")
u.tx.RebindUsb(true)
}
return nil
})
}
func (u *UsbGadget) VerifyMassStorage() error {
if !u.enabledDevices.MassStorage {
return nil
}
massStoragePath := path.Join(u.kvmGadgetPath, "functions/mass_storage.usb0")
if _, err := os.Stat(massStoragePath); err != nil {
return fmt.Errorf("mass_storage function not found: %w", err)
}
lunPath := path.Join(massStoragePath, "lun.0")
if _, err := os.Stat(lunPath); err != nil {
return fmt.Errorf("mass_storage LUN not found: %w", err)
}
configLink := path.Join(u.configC1Path, "mass_storage.usb0")
if _, err := os.Lstat(configLink); err != nil {
return fmt.Errorf("mass_storage symlink not found: %w", err)
}
u.log.Info().Msg("mass storage verified")
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"path"
"path/filepath"
"sort"
"strings"
"github.com/rs/zerolog"
)
@@ -151,7 +152,10 @@ func (tx *UsbGadgetTransaction) CreateConfigPath() {
}
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
tx.log.Info().Msg("=== Building USB gadget configuration ===")
// create kvm gadget path
tx.log.Info().Str("path", tx.kvmGadgetPath).Msg("creating kvm gadget path")
tx.mkdirAll(
"gadget",
tx.kvmGadgetPath,
@@ -162,22 +166,43 @@ func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
deps := make([]string, 0)
deps = append(deps, tx.kvmGadgetPath)
enabledCount := 0
disabledCount := 0
for _, val := range tx.orderedConfigItems {
key := val.key
item := val.item
// check if the item is enabled in the config
if !tx.isGadgetConfigItemEnabled(key) {
tx.log.Debug().
Str("key", key).
Str("device", item.device).
Msg("disabling gadget item (not enabled in config)")
tx.DisableGadgetItemConfig(item)
disabledCount++
continue
}
tx.log.Info().
Str("key", key).
Str("device", item.device).
Uint("order", item.order).
Msg("configuring gadget item")
deps = tx.writeGadgetItemConfig(item, deps)
enabledCount++
}
tx.log.Info().
Int("enabled_items", enabledCount).
Int("disabled_items", disabledCount).
Msg("gadget items configuration completed")
if tx.isGadgetConfigItemEnabled("mtp") {
tx.log.Info().Msg("MTP enabled, mounting functionfs and binding UDC")
tx.MountFunctionFS()
tx.WriteUDC(true)
} else {
tx.log.Info().Msg("MTP disabled, binding UDC directly")
tx.WriteUDC(false)
}
}
@@ -226,6 +251,22 @@ func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, dep
beforeChange = append(beforeChange, tx.getDisableKeys()...)
}
// 对于 mass storage LUN 属性,需要确保 UDC 未绑定
needsUnbind := strings.Contains(gadgetItemPath, "mass_storage") && strings.Contains(gadgetItemPath, "lun.")
if needsUnbind {
// 添加一个 unbind UDC 的步骤(如果已绑定)
udcPath := path.Join(tx.kvmGadgetPath, "UDC")
tx.addFileChange("udc-unbind", RequestedFileChange{
Key: "udc-unbind-check",
Path: udcPath,
ExpectedState: FileStateFile,
Description: "check and unbind UDC if needed",
DependsOn: files,
When: "beforeChange", // 只在需要时执行
})
beforeChange = append(beforeChange, "udc-unbind-check")
}
if len(item.attrs) > 0 {
// write attributes for the item
files = append(files, tx.writeGadgetAttrs(
@@ -355,9 +396,12 @@ func (tx *UsbGadgetTransaction) WriteUDC(mtpServer bool) {
}
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
unbindPath := path.Join(tx.dwc3Path, "unbind")
bindPath := path.Join(tx.dwc3Path, "bind")
// remove the gadget from the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "unbind"),
Path: unbindPath,
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "unbind UDC",
@@ -366,10 +410,10 @@ func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
})
// bind the gadget to the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "bind"),
Path: bindPath,
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "bind UDC",
DependsOn: []string{path.Join(tx.dwc3Path, "unbind")},
DependsOn: []string{unbindPath},
})
}

View File

@@ -2,9 +2,11 @@ package usbgadget
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"time"
)
@@ -118,6 +120,10 @@ func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
u.onKeyboardStateChange = &f
}
func (u *UsbGadget) SetOnHidDeviceMissing(f func(device string, err error)) {
u.onHidDeviceMissing = &f
}
func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
@@ -177,6 +183,16 @@ func (u *UsbGadget) openKeyboardHidFile() error {
var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil {
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
u.log.Error().
Str("device", "hidg0").
Str("device_name", "keyboard").
Err(err).
Msg("HID device file missing, gadget may need reinitialization")
if u.onHidDeviceMissing != nil {
(*u.onHidDeviceMissing)("keyboard", err)
}
}
return fmt.Errorf("failed to open hidg0: %w", err)
}

View File

@@ -1,8 +1,10 @@
package usbgadget
import (
"errors"
"fmt"
"os"
"strings"
)
var absoluteMouseConfig = gadgetConfigItem{
@@ -69,6 +71,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
var err error
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
if err != nil {
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
u.log.Error().
Str("device", "hidg1").
Str("device_name", "absolute_mouse").
Err(err).
Msg("HID device file missing, gadget may need reinitialization")
if u.onHidDeviceMissing != nil {
(*u.onHidDeviceMissing)("absolute_mouse", err)
}
}
return fmt.Errorf("failed to open hidg1: %w", err)
}
}

View File

@@ -1,8 +1,10 @@
package usbgadget
import (
"errors"
"fmt"
"os"
"strings"
)
var relativeMouseConfig = gadgetConfigItem{
@@ -59,7 +61,19 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
var err error
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg1: %w", err)
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
u.log.Error().
Str("device", "hidg2").
Str("device_name", "relative_mouse").
Err(err).
Msg("HID device file missing, gadget may need reinitialization")
if u.onHidDeviceMissing != nil {
(*u.onHidDeviceMissing)("relative_mouse", err)
}
}
return fmt.Errorf("failed to open hidg2: %w", err)
}
}

View File

@@ -80,6 +80,7 @@ type UsbGadget struct {
txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState)
onHidDeviceMissing *func(device string, err error)
log *zerolog.Logger
@@ -140,8 +141,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
absMouseAccumulatedWheelY: 0,
}
if err := g.Init(); err != nil {
logger.Error().Err(err).Msg("failed to init USB gadget")
return nil
logger.Error().Err(err).Msg("failed to init USB gadget (will retry later)")
}
return g

View File

@@ -9,6 +9,7 @@ import (
"os/exec"
"reflect"
"strconv"
"strings"
"time"
"github.com/pion/webrtc/v4"
@@ -250,6 +251,51 @@ func rpcSetEDID(edid string) error {
return nil
}
func rpcSetForceHpd(forceHpd bool) error {
forceHpdValue := 0
if forceHpd {
forceHpdValue = 1
}
forceHpdPath := "/sys/module/tc35874x/parameters/force_hpd"
err := os.WriteFile(forceHpdPath, []byte(fmt.Sprintf("%d\n", forceHpdValue)), 0644)
if err != nil {
logger.Error().Err(err).Bool("force_hpd", forceHpd).Msg("Failed to set force_hpd parameter")
return fmt.Errorf("failed to set force_hpd parameter: %w", err)
}
logger.Info().Bool("force_hpd", forceHpd).Msg("Force HPD setting applied")
config.ForceHpd = forceHpd
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetForceHpd() (bool, error) {
forceHpdPath := "/sys/module/tc35874x/parameters/force_hpd"
data, err := os.ReadFile(forceHpdPath)
if err != nil {
if os.IsNotExist(err) {
return config.ForceHpd, nil
}
logger.Error().Err(err).Msg("Failed to read force_hpd parameter")
return config.ForceHpd, fmt.Errorf("failed to read force_hpd parameter: %w", err)
}
forceHpdValue := strings.TrimSpace(string(data))
if forceHpdValue == "1" {
return true, nil
} else if forceHpdValue == "0" {
return false, nil
} else {
logger.Warn().Str("force_hpd_value", forceHpdValue).Msg("Unexpected force_hpd value, using config value")
return config.ForceHpd, nil
}
}
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
@@ -1050,6 +1096,7 @@ var rpcHandlers = map[string]RPCHandler{
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
@@ -1057,6 +1104,8 @@ var rpcHandlers = map[string]RPCHandler{
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"reinitializeUsbGadget": {Func: rpcReinitializeUsbGadget},
"reinitializeUsbGadgetSoft": {Func: rpcReinitializeUsbGadgetSoft},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
@@ -1069,6 +1118,8 @@ var rpcHandlers = map[string]RPCHandler{
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"setForceHpd": {Func: rpcSetForceHpd, Params: []string{"forceHpd"}},
"getForceHpd": {Func: rpcGetForceHpd},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
@@ -1154,5 +1205,16 @@ var rpcHandlers = map[string]RPCHandler{
"getEasyTierStatus": {Func: rpcGetEasyTierStatus},
"getEasyTierConfig": {Func: rpcGetEasyTierConfig},
"getEasyTierLog": {Func: rpcGetEasyTierLog},
"startVnt": {Func: rpcStartVnt, Params: []string{"config_mode", "token", "device_id", "name", "server_addr", "config_file", "model", "password"}},
"stopVnt": {Func: rpcStopVnt},
"getVntStatus": {Func: rpcGetVntStatus},
"getVntConfig": {Func: rpcGetVntConfig},
"getVntConfigFile": {Func: rpcGetVntConfigFile},
"getVntLog": {Func: rpcGetVntLog},
"getVntInfo": {Func: rpcGetVntInfo},
"getEasyTierNodeInfo": {Func: rpcGetEasyTierNodeInfo},
"startCloudflared": {Func: rpcStartCloudflared, Params: []string{"token"}},
"stopCloudflared": {Func: rpcStopCloudflared},
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
}

View File

@@ -31,8 +31,9 @@ func Main() {
Interface("app_version", appVersionLocal).
Msg("starting KVM")
go runWatchdog()
//go runWatchdog()
go confirmCurrentSystem() //A/B system
go setForceHpd()
http.DefaultClient.Timeout = 1 * time.Minute

View File

@@ -56,6 +56,7 @@ func initNetwork() error {
},
OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig
config.AppliedNetworkConfig = networkConfig
networkStateChanged()
},
})
@@ -73,6 +74,22 @@ func initNetwork() error {
networkState = state
if config != nil && config.NetworkConfig != nil {
if config.NetworkConfig.PendingReboot.Valid && config.NetworkConfig.PendingReboot.Bool {
if config.AppliedNetworkConfig != nil && network.IsSame(config.AppliedNetworkConfig, *config.NetworkConfig) {
config.NetworkConfig.PendingReboot = null.BoolFrom(false)
_ = SaveConfig()
}
}
}
if config != nil && config.NetworkConfig != nil {
if config.NetworkConfig.PendingReboot.Valid && config.NetworkConfig.PendingReboot.Bool {
config.NetworkConfig.PendingReboot = null.BoolFrom(false)
_ = SaveConfig()
}
}
return nil
}
@@ -88,11 +105,29 @@ func rpcGetNetworkSettings() network.RpcNetworkSettings {
}
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
current := networkState.RpcGetNetworkSettings()
changedCore := !network.IsSame(current.NetworkConfig, settings.NetworkConfig)
if changedCore {
settings.NetworkConfig.PendingReboot = null.BoolFrom(true)
}
s := networkState.RpcSetNetworkSettings(settings)
if s != nil {
return nil, s
}
applied := networkState.RpcGetNetworkSettings()
config.NetworkConfig = &applied.NetworkConfig
// If we just reverted to the same core config as applied, clear pending_reboot
if config.AppliedNetworkConfig != nil {
// create copies ignoring PendingReboot
a := *config.AppliedNetworkConfig
b := applied.NetworkConfig
a.PendingReboot = null.Bool{}
b.PendingReboot = null.Bool{}
if network.IsSame(a, b) {
config.NetworkConfig.PendingReboot = null.BoolFrom(false)
}
}
if err := SaveConfig(); err != nil {
return nil, err
}
@@ -103,3 +138,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
func rpcRenewDHCPLease() error {
return networkState.RpcRenewDHCPLease()
}
func rpcRequestDHCPAddress(ip string) error {
return networkState.RpcRequestDHCPAddress(ip)
}

2
ota.go
View File

@@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
}
var builtAppVersion = "0.0.3+dev"
var builtAppVersion = "0.0.4+dev"
var updateSource = "github"

View File

@@ -90,7 +90,7 @@ export default function DashboardNavbar({
<div className="inline-block shrink-0">
<div className="flex items-center gap-4">
<a
href="https://wiki.luckfox.com/Luckfox-Pico/Download"
href="https://wiki.luckfox.com/intro/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4"

View File

@@ -0,0 +1,46 @@
import { useState } from "react";
export interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
const activeTabContent = tabs.find(tab => tab.id === activeTab)?.content;
return (
<div className="space-y-4">
{/* Tab buttons */}
<div className="flex">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
px-6 py-2.5 text-sm font-medium transition-colors
${
activeTab === tab.id
? "bg-blue-500 text-white"
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
}
`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div>{activeTabContent}</div>
</div>
);
}

View File

@@ -263,6 +263,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<li>
{$at("If using an adapter, ensure it's compatible and functioning correctly")}
</li>
<li>
{$at("Certain motherboards do not support simultaneous multi-display output")}
</li>
<li>
{$at("Ensure source device is not in sleep mode and outputting a signal")}
</li>

View File

@@ -44,6 +44,30 @@ function KeyboardWrapper() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
// State for locked modifier keys
const [lockedModifiers, setLockedModifiers] = useState({
ctrl: false,
alt: false,
meta: false,
shift: false,
});
// Toggle for modifier key behavior: true = lock mode, false = direct trigger mode
const [modifierLockMode, setModifierLockMode] = useState(true);
// Clear locked modifiers when switching to direct mode
useEffect(() => {
if (!modifierLockMode) {
setLockedModifiers({
ctrl: false,
alt: false,
meta: false,
shift: false,
});
setLayoutName("default");
}
}, [modifierLockMode]);
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
// HID related states
@@ -132,11 +156,62 @@ function KeyboardWrapper() {
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Check if this is a modifier key press
const isModifierKey = key === "ControlLeft" || key === "AltLeft" || key === "MetaLeft" ||
key === "AltRight" || key === "MetaRight" || isKeyShift;
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
// Handle modifier key press
if (key === "ControlLeft") {
if (modifierLockMode) {
// Lock mode: toggle lock state
setLockedModifiers(prev => ({ ...prev, ctrl: !prev.ctrl }));
} else {
// Direct trigger mode: send key press and release immediately
sendKeyboardEvent([], [modifiers["ControlLeft"]]);
setTimeout(resetKeyboardState, 100);
}
return;
}
if (key === "AltLeft" || key === "AltRight") {
if (modifierLockMode) {
setLockedModifiers(prev => ({ ...prev, alt: !prev.alt }));
} else {
sendKeyboardEvent([], [modifiers[key]]);
setTimeout(resetKeyboardState, 100);
}
return;
}
if (key === "MetaLeft" || key === "MetaRight") {
if (modifierLockMode) {
setLockedModifiers(prev => ({ ...prev, meta: !prev.meta }));
} else {
sendKeyboardEvent([], [modifiers[key]]);
setTimeout(resetKeyboardState, 100);
}
return;
}
if (isKeyShift) {
if (modifierLockMode) {
setLockedModifiers(prev => ({ ...prev, shift: !prev.shift }));
if (lockedModifiers.shift) {
// If unlocking shift, return to default layout
setLayoutName("default");
} else {
// If locking shift, switch to shift layout
toggleLayout();
}
} else {
sendKeyboardEvent([], [modifiers["ShiftLeft"]]);
setTimeout(resetKeyboardState, 100);
}
return;
}
if (key === "CtrlAltDelete") {
sendKeyboardEvent(
[keys["Delete"]],
@@ -166,7 +241,7 @@ function KeyboardWrapper() {
return;
}
if (isKeyShift || isKeyCaps) {
if (isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
@@ -185,25 +260,61 @@ function KeyboardWrapper() {
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
const newModifiers: number[] = [];
// Add locked modifiers
if (lockedModifiers.ctrl) {
newModifiers.push(modifiers["ControlLeft"]);
}
if (lockedModifiers.alt) {
newModifiers.push(modifiers["AltLeft"]);
}
if (lockedModifiers.meta) {
newModifiers.push(modifiers["MetaLeft"]);
}
if (lockedModifiers.shift && !isCapsLockActive) {
newModifiers.push(modifiers["ShiftLeft"]);
}
// Add shift modifier for keys with parentheses (if not caps lock and shift not locked)
if (keyHasShiftModifier && !isCapsLockActive && !lockedModifiers.shift) {
newModifiers.push(modifiers["ShiftLeft"]);
}
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
// If shift was used as a modifier and caps lock is not active and shift is not locked, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive && !lockedModifiers.shift) {
setLayoutName("default");
}
// Auto-unlock modifiers after regular key press (not for combination keys)
if (!isModifierKey && newKeys.length > 0) {
setLockedModifiers({
ctrl: false,
alt: false,
meta: false,
shift: false,
});
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
},
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive, lockedModifiers, modifierLockMode],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const modifierLockButtons = [
lockedModifiers.ctrl ? "ControlLeft" : "",
lockedModifiers.alt ? "AltLeft AltRight" : "",
lockedModifiers.meta ? "MetaLeft MetaRight" : "",
lockedModifiers.shift ? "ShiftLeft ShiftRight" : "",
].filter(Boolean).join(" ").trim();
return (
<div
className="transition-all duration-500 ease-in-out"
@@ -259,9 +370,11 @@ function KeyboardWrapper() {
/>
)}
</div>
<div className="flex flex-col items-center gap-y-1">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
{$at("Virtual Keyboard")}
</h2>
</div>
<div className="absolute right-2">
<Button
size="XS"
@@ -274,21 +387,64 @@ function KeyboardWrapper() {
</div>
<div>
{/* First row with Lock Mode and combination keys */}
<div className="flex items-center bg-blue-50/80 md:flex-row dark:bg-slate-700 gap-x-2 px-2 py-1">
{/* Lock Mode toggle - positioned before Ctrl+Alt+Delete */}
<div className="flex items-center gap-x-2">
<span className="text-[10px] text-slate-600 dark:text-slate-400 whitespace-nowrap">
{$at("Lock Mode")}
</span>
<button
onClick={() => setModifierLockMode(!modifierLockMode)}
className={cx(
"relative inline-flex h-4 w-8 items-center rounded-full transition-colors",
modifierLockMode ? "bg-blue-500" : "bg-slate-300 dark:bg-slate-600"
)}
title={modifierLockMode ? $at("Click to switch to direct trigger mode") : $at("Click to switch to lock mode")}
>
<span
className={cx(
"inline-block h-3 w-3 transform rounded-full bg-white transition-transform",
modifierLockMode ? "translate-x-4" : "translate-x-1"
)}
/>
</button>
</div>
{/* Combination keys */}
<div className="flex items-center gap-x-1">
<button
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
onClick={() => onKeyDown("CtrlAltDelete")}
>
Ctrl + Alt + Delete
</button>
<button
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
onClick={() => onKeyDown("AltMetaEscape")}
>
Alt + Meta + Escape
</button>
<button
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
onClick={() => onKeyDown("CtrlAltBackspace")}
>
Ctrl + Alt + Backspace
</button>
</div>
</div>
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
},
]}
buttonTheme={
modifierLockMode && modifierLockButtons
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
: []
}
display={keyDisplayMap}
layout={{
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
@@ -297,7 +453,6 @@ function KeyboardWrapper() {
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",

View File

@@ -10,6 +10,7 @@ import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import { chars } from "@/keyboardLayouts";
import {
useHidStore,
useMouseStore,
@@ -30,15 +31,25 @@ export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const audioElm = useRef<HTMLAudioElement>(null);
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [mobileScale, setMobileScale] = useState(1);
const [mobileTx, setMobileTx] = useState(0);
const [mobileTy, setMobileTy] = useState(0);
const activeTouchPointers = useRef<Map<number, { x: number; y: number }>>(new Map());
const initialPinchDistance = useRef<number | null>(null);
const initialPinchScale = useRef<number>(1);
const lastPanPoint = useRef<{ x: number; y: number } | null>(null);
const lastTapAt = useRef<number>(0);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
@@ -78,6 +89,66 @@ export default function WebRTCVideo() {
// Misc states and hooks
const [send] = useJsonRpc();
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0) return keyboardLayout;
return "en_US";
}, [keyboardLayout]);
const sendTextViaHID = useCallback(async (t: string) => {
for (const ch of t) {
const mapping = chars[safeKeyboardLayout][ch];
if (!mapping || !mapping.key) continue;
const { key, shift, altRight, deadKey, accentKey } = mapping;
const keyz = [keys[key]];
const modz = [(shift ? modifiers["ShiftLeft"] : 0) | (altRight ? modifiers["AltRight"] : 0)];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(0);
}
if (accentKey) {
keyz.unshift(keys[accentKey.key as keyof typeof keys]);
modz.unshift(((accentKey.shift ? modifiers["ShiftLeft"] : 0) | (accentKey.altRight ? modifiers["AltRight"] : 0)));
}
for (const [index, kei] of keyz.entries()) {
await new Promise<void>((resolve, reject) => {
send("keyboardReport", { keys: [kei], modifier: modz[index] }, params => {
if ("error" in params) return reject(params.error as unknown as Error);
send("keyboardReport", { keys: [], modifier: 0 }, params => {
if ("error" in params) return reject(params.error as unknown as Error);
resolve();
});
});
});
}
}
}, [send, safeKeyboardLayout]);
const handleGlobalPaste = useCallback(async (e: ClipboardEvent) => {
if (!overrideCtrlV) return;
e.preventDefault();
const txt = e.clipboardData?.getData("text") || "";
if (!txt) return;
const invalid = [
...new Set(
// @ts-expect-error
[...new Intl.Segmenter().segment(txt)].map(x => x.segment).filter(ch => !chars[safeKeyboardLayout][ch]),
),
];
if (invalid.length > 0) {
notifications.error(`Invalid characters: ${invalid.join(", ")}`);
return;
}
if (isReinitializingGadget) return;
try {
await sendTextViaHID(txt);
notifications.success(`Pasted: "${txt}"`);
} catch {
notifications.error("Failed to paste text");
}
}, [overrideCtrlV, safeKeyboardLayout, isReinitializingGadget, sendTextViaHID]);
// Video-related
useResizeObserver({
ref: videoElm as React.RefObject<HTMLElement>,
@@ -223,7 +294,7 @@ export default function WebRTCVideo() {
}
};
document.addEventListener("fullscreenchange ", handleFullscreenChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
}, [releaseKeyboardLock]);
// Mouse-related
@@ -232,16 +303,24 @@ export default function WebRTCVideo() {
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return;
// Don't send mouse events while reinitializing gadget
if (isReinitializingGadget) return;
// if we ignore the event, double-click will not work
// if (x === 0 && y === 0 && buttons === 0) return;
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
setMouseMove({ x, y, buttons });
},
[send, setMouseMove, settings.mouseMode],
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
);
const relMouseMoveHandler = useCallback(
(e: MouseEvent) => {
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
if (pt === "touch") {
const touchCount = activeTouchPointers.current.size;
if (touchCount >= 2) return;
if (mobileScale > 1 && lastPanPoint.current) return;
}
if (settings.mouseMode !== "relative") return;
if (isPointerLockActive === false && isPointerLockPossible) return;
@@ -255,15 +334,25 @@ export default function WebRTCVideo() {
const sendAbsMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "absolute") return;
// Don't send mouse events while reinitializing gadget
if (isReinitializingGadget) return;
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition, settings.mouseMode],
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
);
const absMouseMoveHandler = useCallback(
(e: MouseEvent) => {
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
if (pt === "touch") {
const touchCount = activeTouchPointers.current.size;
if (touchCount >= 2) return;
if (mobileScale > 1) {
return;
}
}
if (!videoClientWidth || !videoClientHeight) return;
if (settings.mouseMode !== "absolute") return;
@@ -287,9 +376,13 @@ export default function WebRTCVideo() {
offsetY = (videoClientHeight - effectiveHeight) / 2;
}
// Determine input point (reverse transform for touch when zoomed)
const inputOffsetX = pt === "touch" ? Math.max(0, Math.min(videoClientWidth, (e.offsetX - mobileTx) / mobileScale)) : e.offsetX;
const inputOffsetY = pt === "touch" ? Math.max(0, Math.min(videoClientHeight, (e.offsetY - mobileTy) / mobileScale)) : e.offsetY;
// Clamp mouse position within the effective video boundaries
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
const clampedX = Math.min(Math.max(offsetX, inputOffsetX), offsetX + effectiveWidth);
const clampedY = Math.min(Math.max(offsetY, inputOffsetY), offsetY + effectiveHeight);
// Map clamped mouse position to the video stream's coordinate system
const relativeX = (clampedX - offsetX) / effectiveWidth;
@@ -303,11 +396,13 @@ export default function WebRTCVideo() {
const { buttons } = e;
sendAbsMouseMovement(x, y, buttons);
},
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement],
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement, mobileScale, mobileTx, mobileTy],
);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
// Don't send wheel events while reinitializing gadget
if (isReinitializingGadget) return;
if (settings.scrollThrottling && blockWheelEvent) {
return;
@@ -339,7 +434,7 @@ export default function WebRTCVideo() {
setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
}
},
[send, blockWheelEvent, settings],
[send, blockWheelEvent, settings, isReinitializingGadget],
);
const resetMousePosition = useCallback(() => {
@@ -414,6 +509,16 @@ export default function WebRTCVideo() {
const keyDownHandler = useCallback(
async (e: KeyboardEvent) => {
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
console.log("Override Ctrl V");
if (isReinitializingGadget) return;
if (pasteCaptureRef.current) {
pasteCaptureRef.current.value = "";
pasteCaptureRef.current.focus();
}
return;
}
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
@@ -456,10 +561,15 @@ export default function WebRTCVideo() {
[
handleModifierKeys,
sendKeyboardEvent,
send,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
overrideCtrlV,
pasteCaptureRef,
safeKeyboardLayout,
isReinitializingGadget,
],
);
@@ -568,8 +678,7 @@ export default function WebRTCVideo() {
);
// Setup Keyboard Events
useEffect(
function setupKeyboardEvents() {
useEffect(function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
@@ -582,9 +691,7 @@ export default function WebRTCVideo() {
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState],
);
}, [keyDownHandler, keyUpHandler, resetKeyboardState]);
// Setup Video Event Listeners
useEffect(
@@ -608,6 +715,14 @@ export default function WebRTCVideo() {
[onVideoPlaying, videoKeyUpHandler],
);
// Setup Global Paste Listener (register after handleGlobalPaste is defined)
useEffect(function setupPasteListener() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("paste", handleGlobalPaste, { signal });
return () => abortController.abort();
}, [handleGlobalPaste]);
// Setup Mouse Events
useEffect(
function setMouseModeEventListeners() {
@@ -652,6 +767,90 @@ export default function WebRTCVideo() {
);
const containerRef = useRef<HTMLDivElement>(null);
const zoomLayerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = zoomLayerRef.current;
if (!el) return;
const abortController = new AbortController();
const signal = abortController.signal;
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
try { el.setPointerCapture(e.pointerId); } catch {}
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activeTouchPointers.current.size === 1) {
const now = Date.now();
if (now - lastTapAt.current < 300) {
setMobileScale(1);
setMobileTx(0);
setMobileTy(0);
}
lastTapAt.current = now;
lastPanPoint.current = { x: e.clientX, y: e.clientY };
} else if (activeTouchPointers.current.size === 2) {
const pts = Array.from(activeTouchPointers.current.values());
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
initialPinchDistance.current = d;
initialPinchScale.current = mobileScale;
}
e.preventDefault();
e.stopPropagation();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
const prev = activeTouchPointers.current.get(e.pointerId);
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
const pts = Array.from(activeTouchPointers.current.values());
if (pts.length === 2 && initialPinchDistance.current) {
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
const factor = d / initialPinchDistance.current;
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
setMobileScale(next);
} else if (pts.length === 1 && lastPanPoint.current && prev) {
const dx = e.clientX - lastPanPoint.current.x;
const dy = e.clientY - lastPanPoint.current.y;
lastPanPoint.current = { x: e.clientX, y: e.clientY };
setMobileTx(v => v + dx);
setMobileTy(v => v + dy);
}
e.preventDefault();
e.stopPropagation();
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
activeTouchPointers.current.delete(e.pointerId);
if (activeTouchPointers.current.size < 2) {
initialPinchDistance.current = null;
}
if (activeTouchPointers.current.size === 0) {
lastPanPoint.current = null;
}
e.preventDefault();
e.stopPropagation();
};
el.addEventListener("pointerdown", onPointerDown, { signal });
el.addEventListener("pointermove", onPointerMove, { signal });
el.addEventListener("pointerup", onPointerUp, { signal });
el.addEventListener("pointercancel", onPointerUp, { signal });
return () => abortController.abort();
}, [mobileScale]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const cw = container.clientWidth;
const ch = container.clientHeight;
if (!cw || !ch) return;
const maxX = (cw * (mobileScale - 1)) / 2;
const maxY = (ch * (mobileScale - 1)) / 2;
setMobileTx(x => Math.max(-maxX, Math.min(maxX, x)));
setMobileTy(y => Math.max(-maxY, Math.min(maxY, y)));
}, [mobileScale]);
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false;
@@ -710,7 +909,15 @@ export default function WebRTCVideo() {
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
<PointerLockBar show={showPointerLockBar} />
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center">
<div
ref={zoomLayerRef}
className="relative flex h-full w-full items-center justify-center"
style={{
transform: `translate(${mobileTx}px, ${mobileTy}px) scale(${mobileScale})`,
transformOrigin: "center center",
touchAction: "none",
}}
>
<video
ref={videoElm}
autoPlay={true}
@@ -767,6 +974,37 @@ export default function WebRTCVideo() {
<div>
<InfoBar />
</div>
<textarea
ref={pasteCaptureRef}
aria-hidden="true"
style={{ position: "fixed", left: -9999, top: -9999, width: 1, height: 1, opacity: 0 }}
onPaste={async e => {
console.log("Paste event");
if (!overrideCtrlV) return;
e.preventDefault();
const txt = e.clipboardData?.getData("text") || e.currentTarget.value || "";
e.currentTarget.blur();
if (txt) {
const invalid = [
...new Set(
// @ts-expect-error
[...new Intl.Segmenter().segment(txt)].map(x => x.segment).filter(ch => !chars[safeKeyboardLayout][ch]),
),
];
if (invalid.length > 0) {
notifications.error(`Invalid characters: ${invalid.join(", ")}`);
return;
}
if (isReinitializingGadget) return;
try {
await sendTextViaHID(txt);
notifications.success(`Pasted: "${txt}"`);
} catch {
notifications.error("Failed to paste text");
}
}
}}
/>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useJsonRpc } from "../../hooks/useJsonRpc";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
interface ATXState {
power: boolean;
hdd: boolean;
}
export function ATXPowerControl() {
const [isPowerPressed, setIsPowerPressed] = useState(false);
const [powerPressTimer, setPowerPressTimer] = useState<ReturnType<
typeof setTimeout
> | null>(null);
const [atxState, setAtxState] = useState<ATXState | null>(null);
const [send] = useJsonRpc(function onRequest(resp) {
if (resp.method === "atxState") {
setAtxState(resp.params as ATXState);
}
});
// Request initial state
useEffect(() => {
send("getATXState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAtxState(resp.result as ATXState);
});
}, [send]);
const handlePowerPress = (pressed: boolean) => {
// Prevent phantom releases
if (!pressed && !isPowerPressed) return;
setIsPowerPressed(pressed);
// Handle button press
if (pressed) {
// Start long press timer
const timer = setTimeout(() => {
// Send long press action
console.log("Sending long press ATX power action");
send("setATXPowerAction", { action: "power-long" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
setIsPowerPressed(false);
});
}, LONG_PRESS_DURATION);
setPowerPressTimer(timer);
}
// Handle button release
else {
// If timer exists, was a short press
if (powerPressTimer) {
clearTimeout(powerPressTimer);
setPowerPressTimer(null);
// Send short press action
console.log("Sending short press ATX power action");
send("setATXPowerAction", { action: "power-short" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
});
}
}
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (powerPressTimer) {
clearTimeout(powerPressTimer);
}
};
}, [powerPressTimer]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="ATX Power Control"
description="Control your ATX power settings"
/>
{atxState === null ? (
<Card className="flex h-[120px] items-center justify-center p-3">
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[120px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Control Buttons */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power"
onMouseDown={() => handlePowerPress(true)}
onMouseUp={() => handlePowerPress(false)}
onMouseLeave={() => handlePowerPress(false)}
className={isPowerPressed ? "opacity-75" : ""}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuRotateCcw}
text="Reset"
onClick={() => {
send("setATXPowerAction", { action: "reset" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Indicators */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuPower
strokeWidth={3}
className={`mr-1 inline ${
atxState?.power ? "text-green-600" : "text-slate-300"
}`}
/>
Power LED
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuHardDrive
strokeWidth={3}
className={`mr-1 inline ${
atxState?.hdd ? "text-blue-400" : "text-slate-300"
}`}
/>
HDD LED
</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts";
import notifications from "@/notifications";
import {useReactAt} from 'i18n-auto-extractor/react'
import { Checkbox } from "@/components/Checkbox";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
@@ -28,11 +29,15 @@ export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
const [pasteBuffer, setPasteBuffer] = useState<string>("");
const close = useClose();
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
@@ -59,31 +64,38 @@ export default function PasteModal() {
setPasteMode(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
// keep override state persistent via settings store; do not reset here
}, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
// Don't send keyboard events while reinitializing gadget
if (isReinitializingGadget) {
notifications.error("USB gadget is reinitializing, please wait...");
return;
}
if (!safeKeyboardLayout) return;
if (!chars[safeKeyboardLayout]) return;
const text = TextAreaRef.current.value;
const sendText = async (t: string) => {
try {
for (const char of text) {
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
if (!key) continue;
for (const char of t) {
const mapping = chars[safeKeyboardLayout][char];
if (!mapping || !mapping.key) continue;
const { key, shift, altRight, deadKey, accentKey } = mapping;
const keyz = [ keys[key] ];
const modz = [ modifierCode(shift, altRight) ];
const keyz = [keys[key]];
const modz = [modifierCode(shift, altRight)];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) {
keyz.unshift(keys[accentKey.key])
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
keyz.unshift(keys[accentKey.key]);
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
}
for (const [index, kei] of keyz.entries()) {
@@ -102,11 +114,76 @@ export default function PasteModal() {
});
}
}
notifications.success(`Pasted: "${t}"`);
} catch (error) {
console.error(error);
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
};
await sendText(text);
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout, isReinitializingGadget]);
const readClipboardToBufferAndSend = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
setPasteBuffer(text);
const segInvalid = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(text)]
.map(x => x.segment)
.filter(char => !chars[safeKeyboardLayout][char]),
),
];
setInvalidChars(segInvalid);
if (segInvalid.length === 0) {
if (rpcDataChannel?.readyState !== "open" || isReinitializingGadget) return;
const sendText = async (t: string) => {
try {
for (const char of t) {
const mapping = chars[safeKeyboardLayout][char];
if (!mapping || !mapping.key) continue;
const { key, shift, altRight, deadKey, accentKey } = mapping;
const keyz = [keys[key]];
const modz = [modifierCode(shift, altRight)];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) {
keyz.unshift(keys[accentKey.key]);
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
}
for (const [index, kei] of keyz.entries()) {
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([kei], modz[index]),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
}
notifications.success(`Pasted: "${t}"`);
} catch (error) {
notifications.error("Failed to paste text");
}
};
await sendText(text);
} else {
notifications.error(`Invalid characters: ${segInvalid.join(", ")}`);
}
} catch {}
}, [safeKeyboardLayout, rpcDataChannel?.readyState, isReinitializingGadget, send]);
useEffect(() => {
if (TextAreaRef.current) {
@@ -125,6 +202,18 @@ export default function PasteModal() {
description={$at("Paste text from your client to the remote host")}
/>
<div className="flex items-center">
<label className="flex items-center gap-x-2 text-sm">
<Checkbox
checked={overrideCtrlV}
onChange={e => setOverrideCtrlV(e.target.checked)}
/>
<span className="text-slate-700 dark:text-slate-300">
{$at("Use Ctrl+V to paste clipboard to remote")}
</span>
</label>
</div>
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
@@ -133,7 +222,83 @@ export default function PasteModal() {
}}
>
<div>
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<div
className="w-full"
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
readClipboardToBufferAndSend();
}
}}
onPaste={e => {
if (overrideCtrlV) {
e.preventDefault();
const txt = e.clipboardData?.getData("text") || "";
if (txt) {
setPasteBuffer(txt);
const segInvalid = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(txt)]
.map(x => x.segment)
.filter(char => !chars[safeKeyboardLayout][char]),
),
];
setInvalidChars(segInvalid);
if (segInvalid.length === 0) {
if (rpcDataChannel?.readyState === "open" && !isReinitializingGadget) {
const sendText = async (t: string) => {
try {
for (const char of t) {
const mapping = chars[safeKeyboardLayout][char];
if (!mapping || !mapping.key) continue;
const { key, shift, altRight, deadKey, accentKey } = mapping;
const keyz = [keys[key]];
const modz = [modifierCode(shift, altRight)];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) {
keyz.unshift(keys[accentKey.key]);
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
}
for (const [index, kei] of keyz.entries()) {
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([kei], modz[index]),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
}
notifications.success(`Pasted: "${t}"`);
} catch (error) {
notifications.error("Failed to paste text");
}
};
sendText(txt);
}
} else {
notifications.error(`Invalid characters: ${segInvalid.join(", ")}`);
}
} else {
readClipboardToBufferAndSend();
}
}
}}
>
{!overrideCtrlV && (
<>
<TextAreaWithLabel
ref={TextAreaRef}
label={$at("Paste from host")}
@@ -172,6 +337,8 @@ export default function PasteModal() {
</span>
</div>
)}
</>
)}
</div>
</div>
<div className="space-y-4">
@@ -199,6 +366,7 @@ export default function PasteModal() {
close();
}}
/>
{!overrideCtrlV && (
<Button
size="SM"
theme="primary"
@@ -206,6 +374,7 @@ export default function PasteModal() {
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>
)}
</div>
</div>
</GridCard>

View File

@@ -346,6 +346,9 @@ interface SettingsState {
showPressedKeys: boolean;
setShowPressedKeys: (show: boolean) => void;
overrideCtrlV: boolean;
setOverrideCtrlV: (enabled: boolean) => void;
// Video enhancement settings
videoSaturation: number;
setVideoSaturation: (value: number) => void;
@@ -409,6 +412,9 @@ export const useSettingsStore = create(
showPressedKeys: true,
setShowPressedKeys: show => set({ showPressedKeys: show }),
overrideCtrlV: false,
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
// Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0,
setVideoSaturation: value => set({ videoSaturation: value }),
@@ -524,6 +530,9 @@ export interface HidState {
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed" | "default";
setUsbState: (state: HidState["usbState"]) => void;
isReinitializingGadget: boolean;
setIsReinitializingGadget: (reinitializing: boolean) => void;
}
export const useHidStore = create<HidState>((set, get) => ({
@@ -571,6 +580,9 @@ export const useHidStore = create<HidState>((set, get) => ({
// Add these new properties for USB state
usbState: "not attached",
setUsbState: state => set({ usbState: state }),
isReinitializingGadget: false,
setIsReinitializingGadget: reinitializing => set({ isReinitializingGadget: reinitializing }),
}));
@@ -808,15 +820,25 @@ export type TimeSyncMode =
| "custom"
| "unknown";
export interface IPv4StaticConfig {
address?: string;
netmask?: string;
gateway?: string;
dns?: string[];
}
export interface NetworkSettings {
hostname: string;
domain: string;
ipv4_mode: IPv4Mode;
ipv4_request_address?: string;
ipv4_static?: IPv4StaticConfig;
ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode;
lldp_tx_tlvs: string[];
mdns_mode: mDNSMode;
time_sync_mode: TimeSyncMode;
pending_reboot?: boolean;
}
export const useNetworkStateStore = create<NetworkState>((set, get) => ({

View File

@@ -57,7 +57,13 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
// The "API" can also "request" data from the client
// If the payload has a method, it's a request
if ("method" in payload) {
if (onRequest) onRequest(payload);
if ((payload as JsonRpcRequest).method === "refreshPage") {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set("networkChanged", "true");
window.location.href = currentUrl.toString();
return;
}
if (onRequest) onRequest(payload as JsonRpcRequest);
return;
}

View File

@@ -1,4 +1,5 @@
import { useCallback } from "react";
import notifications from "@/notifications";
import { useHidStore, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
@@ -11,18 +12,31 @@ export default function useKeyboard() {
const updateActiveKeysAndModifiers = useHidStore(
state => state.updateActiveKeysAndModifiers,
);
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const usbState = useHidStore(state => state.usbState);
const sendKeyboardEvent = useCallback(
(keys: number[], modifiers: number[]) => {
if (rpcDataChannel?.readyState !== "open") return;
// Don't send keyboard events while reinitializing gadget
if (isReinitializingGadget) return;
if (usbState !== "configured") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
send("keyboardReport", { keys, modifier: accModifier });
send("keyboardReport", { keys, modifier: accModifier }, resp => {
if ("error" in resp) {
const msg = (resp.error.data as string) || resp.error.message || "";
if (msg.includes("cannot send after transport endpoint shutdown") && usbState === "configured") {
notifications.error("Please check if the cable and connection are stable.", { duration: 5000 });
}
}
});
// We do this for the info bar to display the currently pressed keys for the user
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
},
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
);
const resetKeyboardState = useCallback(() => {

View File

@@ -315,6 +315,16 @@ video::-webkit-media-controls {
@apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
}
.hg-theme-default .hg-button.modifier-locked {
@apply bg-blue-500! text-white! border-blue-600!;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5) !important;
}
.dark .hg-theme-default .hg-button.modifier-locked {
@apply bg-blue-600! text-white! border-blue-700!;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.7) !important;
}
.hg-theme-default .hg-row .hg-button-container,
.hg-theme-default .hg-row .hg-button:not(:last-child) {
@apply mr-[2px]! md:mr-[5px]!;

View File

@@ -1,6 +1,8 @@
{
"1281764038": "Unmount",
"2188692750": "Send a Signal to wake up the device connected via USB.",
"2191159446": "Save Static IPv4 Settings?",
"2316865986": "Name (Optional)",
"3899981764": "You can only add a maximum of",
"514d8a494f": "Terminal",
"ef4f580086": "Paste Text",
@@ -77,6 +79,7 @@
"a90c4aa86f": "Ensure the HDMI cable securely connected at both ends",
"6fd1357371": "Ensure source device is powered on and outputting a signal",
"3ea74f7e5e": "If using an adapter, ensure it's compatible and functioning correctly",
"db130e0e66": "Certain motherboards do not support simultaneous multi-display output",
"54b1e31a57": "Ensure source device is not in sleep mode and outputting a signal",
"693a670a35": "Try Wakeup",
"d59048f21f": "Learn more",
@@ -87,6 +90,9 @@
"8fc3022cab": "Click on the video to enable mouse control",
"f200763a0d": "Detach",
"7193518e6f": "Attach",
"1c6a47f19f": "Lock Mode",
"b2759484fd": "Click to switch to direct trigger mode",
"80461c2be2": "Click to switch to lock mode",
"703703879d": "IO Control",
"4ae517d1d2": "Configure your io control settings",
"258f49887e": "Up",
@@ -113,6 +119,7 @@
"ae94be3cd5": "Manager",
"5fe0273ec6": "Paste text",
"2a5a0639a5": "Paste text from your client to the remote host",
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
"6d161c8084": "Paste from host",
"604c45fbf2": "The following characters will not be pasted:",
"a7eb9efa0b": "Sending text using keyboard layout:",
@@ -217,33 +224,53 @@
"3d0de21428": "Update your device access password",
"f8508f576c": "Remote",
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
"e4abe63a8b": "Connect to TailScale VPN network",
"0b86461350": "TailScale use xEdge server",
"a7d199ad4f": "Login URL:",
"3bef87ee46": "Wait to obtain the Login URL",
"a3060e541f": "Quitting...",
"0baef6200d": "Network IP",
"2faec1f9f8": "Enable",
"761c43fec5": "Connect to ZeroTier VPN network",
"3cc3bd7438": "Connecting...",
"5028274560": "Network ID",
"0baef6200d": "Network IP",
"a2c4bef9fa": "Connect fail, please retry",
"6327b4e59f": "Retry",
"37cfbb7f44": "Enter ZeroTier Network ID",
"718f8fd90c": "Join in",
"18c514b621": "Connect to EasyTier server",
"8fc22b3dac": "Network Node",
"a3e117fed1": "Network Name",
"5f1ffd341a": "Network Secret",
"8fc22b3dac": "Network Node",
"11a755d598": "Stop",
"ce0be71e33": "Log",
"3119fca100": "Node Info",
"3b92996a28": "Enter EasyTier Network Name",
"a4c4c07b3d": "Enter EasyTier Network Secret",
"7a1920d611": "Default",
"63e0339544": "Enter EasyTier Network Node",
"3b92996a28": "Enter EasyTier Network Name",
"a4c4c07b3d": "Enter EasyTier Network Secret",
"a6122a65ea": "Start",
"03eb0dbd4f": "Connect to Frp server",
"31a631e8bc": "Config Mode",
"c2a012144d": "Config File",
"3225a10b07": "Parameters",
"fa535ffb25": "Config",
"ce545e8797": "Using config file",
"459a6f79ad": "Token",
"2e8f11ede1": "Device ID",
"49ee308734": "Name",
"a4d911beb0": "Server Address",
"4059b0251f": "Info",
"493fbda223": "Edit vnt.ini",
"1c1aacac07": "Enter vnt-cli configuration",
"788982fb30": "Token (Required)",
"7e921a758c": "Enter Vnt Token",
"1644a3594c": "Device ID (Optional)",
"a6ad3901c6": "Enter Device ID",
"2fadcf358b": "Enter Device Name",
"0be4dc6ce1": "Server Address (Optional)",
"723e86b659": "Enter Server Address",
"2a766ad220": "Encryption Algorithm",
"b24203b84f": "Password(Optional)",
"c412ab8687": "Enter Vnt Password",
"af0d799cbf": "Cloudflare Tunnel Token",
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
"1bbabcdef3": "Edit frpc.toml",
"9c088a303a": "Enter frpc configuration",
"867cee98fd": "Passwords do not match",
@@ -291,9 +318,18 @@
"5c43d74dbd": "Control the USB emulation state",
"f6c8ddbadf": "Disable USB Emulation",
"020b92cfbb": "Enable USB Emulation",
"9f55f64b0f": "USB Gadget Reinitialize",
"40dc677a89": "Reinitialize USB gadget configuration",
"02d2f33ec9": "Reinitialize USB Gadget",
"f5ddf02991": "Reboot System",
"1dbbf194af": "Restart the device system",
"1de72c4fc6": "Reboot",
"f43c0398a4": "Reset Configuration",
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
"0d784092e8": "Reset Config",
"a776e925bf": "Reboot System?",
"1f070051ff": "Are you sure you want to reboot the system?",
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
"a1c58e9422": "Appearance",
"d414b664a7": "Customize the look and feel of your KVM interface",
"d721757161": "Theme",
@@ -391,12 +427,21 @@
"d4dccb8ca2": "Save settings",
"8750a898cb": "IPv4 Mode",
"72c2543791": "Configure IPv4 mode",
"168d88811a": "Effective Upon Reboot",
"4805e7f806": "Request Address",
"536f68587c": "Netmask",
"926dec9494": "Gateway",
"94c252be0e": "DHCP Information",
"902f16cd13": "No DHCP lease information available",
"6a802c3684": "IPv6 Mode",
"d29b71c737": "Configure the IPv6 mode",
"d323009843": "No IPv6 addresses configured",
"892eec6b1a": "This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.",
"676228894e": "Changes will take effect after a restart.",
"70d9be9b13": "Confirm",
"50cfb85440": "Save Request Address?",
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
"cc172c234b": "Change IPv4 Mode?",
"a344a29861": "IPv4 mode changes will take effect after a restart.",
"697b29c12e": "Back to KVM",
"34e2d1989a": "Video",
"2c50ab9cb6": "Configure display settings and EDID for optimal compatibility",
@@ -411,6 +456,8 @@
"c63ecd19a0": "Contrast",
"5a31e20e6d": "Contrast level",
"4418069f82": "Reset to Default",
"839b2e1447": "Force EDID Output",
"66e3bd652e": "Force EDID output even when no display is connected",
"25793bfcbb": "Adjust the EDID settings for the display",
"34d026e0a9": "Custom EDID",
"b0258b2bdb": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",

View File

@@ -1,6 +1,8 @@
{
"1281764038": "卸载",
"2188692750": "发送信号以唤醒通过 USB 连接的设备。",
"2191159446": "保存静态 IPv4 设置?",
"2316865986": "名称(可选)",
"3899981764": "你最多只能添加",
"514d8a494f": "终端",
"ef4f580086": "粘贴文本",
@@ -77,6 +79,7 @@
"a90c4aa86f": "确保 HDMI 线缆两端连接牢固",
"6fd1357371": "确保源设备已开机并输出信号",
"3ea74f7e5e": "如果使用适配器,确保其兼容且工作正常",
"db130e0e66": "部分主板不支持同时输出多路视频信号",
"54b1e31a57": "确保源设备未处于睡眠模式且正在输出信号",
"693a670a35": "尝试唤醒",
"d59048f21f": "了解更多",
@@ -87,6 +90,9 @@
"8fc3022cab": "点击视频以启用鼠标控制",
"f200763a0d": "分离",
"7193518e6f": "固定",
"1c6a47f19f": "锁定模式",
"b2759484fd": "点击切换到直接触发模式",
"80461c2be2": "点击切换到锁定模式",
"703703879d": "IO 控制",
"4ae517d1d2": "配置您的 IO 输出电平状态",
"258f49887e": "高电平",
@@ -113,6 +119,7 @@
"ae94be3cd5": "管理",
"5fe0273ec6": "粘贴文本",
"2a5a0639a5": "将文本从客户端粘贴到远程主机",
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
"6d161c8084": "从主机粘贴",
"604c45fbf2": "以下字符将不会被粘贴:",
"a7eb9efa0b": "使用键盘布局发送文本:",
@@ -217,33 +224,53 @@
"3d0de21428": "更新设备访问密码",
"f8508f576c": "远程",
"a8bb6f5f9f": "管理远程访问设备的方式",
"e4abe63a8b": "连接到 TailScale VPN 网络",
"0b86461350": "TailScale 使用 xEdge 服务器",
"a7d199ad4f": "登录网址:",
"3bef87ee46": "等待获取登录网址",
"a3060e541f": "退出中...",
"0baef6200d": "网络 IP",
"2faec1f9f8": "启用",
"761c43fec5": "连接到 ZeroTier VPN 网络",
"3cc3bd7438": "连接中...",
"5028274560": "网络 ID",
"0baef6200d": "网络 IP",
"a2c4bef9fa": "连接失败,请重试",
"6327b4e59f": "重试",
"37cfbb7f44": "输入 ZeroTier 网络 ID",
"718f8fd90c": "加入",
"18c514b621": "连接到 EasyTier 服务器",
"8fc22b3dac": "网络节点",
"a3e117fed1": "网络名称",
"5f1ffd341a": "网络密钥",
"8fc22b3dac": "网络节点",
"11a755d598": "停止",
"ce0be71e33": "日志",
"3119fca100": "节点信息",
"3b92996a28": "输入 EasyTier 网络名称",
"a4c4c07b3d": "输入 EasyTier 网络密钥",
"7a1920d611": "默认",
"63e0339544": "输入 EasyTier 网络节点",
"3b92996a28": "输入 EasyTier 网络名称",
"a4c4c07b3d": "输入 EasyTier 网络密钥",
"a6122a65ea": "启动",
"03eb0dbd4f": "连接到 Frp 服务器",
"31a631e8bc": "配置模式",
"c2a012144d": "配置文件",
"3225a10b07": "参数",
"fa535ffb25": "配置",
"ce545e8797": "使用配置文件",
"459a6f79ad": "令牌",
"2e8f11ede1": "设备 ID",
"49ee308734": "名称",
"a4d911beb0": "服务器地址",
"4059b0251f": "信息",
"493fbda223": "编辑 vnt.ini",
"1c1aacac07": "输入 vnt-cli 配置",
"788982fb30": "令牌(必需)",
"7e921a758c": "输入 Vnt 令牌",
"1644a3594c": "设备 ID可选",
"a6ad3901c6": "输入设备 ID",
"2fadcf358b": "输入设备名称",
"0be4dc6ce1": "服务器地址(可选)",
"723e86b659": "输入服务器地址",
"2a766ad220": "加密算法",
"b24203b84f": "密码(可选)",
"c412ab8687": "输入 Vnt 密码",
"af0d799cbf": "Cloudflare 通道令牌",
"c420b0d8f0": "输入 Cloudflare 通道令牌",
"1bbabcdef3": "编辑 frpc.toml",
"9c088a303a": "输入 frpc 配置",
"867cee98fd": "密码不一致",
@@ -291,9 +318,18 @@
"5c43d74dbd": "控制 USB 复用状态",
"f6c8ddbadf": "禁用 USB 复用",
"020b92cfbb": "启用 USB 复用",
"9f55f64b0f": "USB 设备重新初始化",
"40dc677a89": "重新初始化 USB 设备配置",
"02d2f33ec9": "重新初始化 USB 设备",
"f5ddf02991": "重启系统",
"1dbbf194af": "重启设备系统",
"1de72c4fc6": "重启",
"f43c0398a4": "重置配置",
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
"0d784092e8": "重置配置",
"a776e925bf": "重启系统?",
"1f070051ff": "你确定重启系统吗?",
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
"a1c58e9422": "外观",
"d414b664a7": "自定义 KVM 界面的外观",
"d721757161": "主题",
@@ -391,12 +427,21 @@
"d4dccb8ca2": "保存设置",
"8750a898cb": "IPv4 模式",
"72c2543791": "配置 IPv4 模式",
"168d88811a": "重启后生效",
"4805e7f806": "申请地址",
"536f68587c": "网络掩码",
"926dec9494": "网关",
"94c252be0e": "DHCP 信息",
"902f16cd13": "无 DHCP 租约信息",
"6a802c3684": "IPv6 模式",
"d29b71c737": "配置 IPv6 模式",
"d323009843": "未配置 IPv6 地址",
"892eec6b1a": "这将请求 DHCP 服务器分配新的 IP 地址。在此过程中,您的设备可能会失去网络连接。",
"676228894e": "更改将在重启后生效。",
"70d9be9b13": "确认",
"50cfb85440": "保存请求的 IPv4 地址?",
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
"cc172c234b": "更改 IPv4 模式?",
"a344a29861": "IPv4 模式更改将在重启后生效。",
"697b29c12e": "返回 KVM",
"34e2d1989a": "视频",
"2c50ab9cb6": "配置视频显示和 EDID",
@@ -411,6 +456,8 @@
"c63ecd19a0": "对比度",
"5a31e20e6d": "对比度级别",
"4418069f82": "恢复默认",
"839b2e1447": "强制 EDID 输出",
"66e3bd652e": "强制 EDID 输出,即使没有连接显示器",
"25793bfcbb": "调整显示器的 EDID 设置",
"34d026e0a9": "自定义 EDID",
"b0258b2bdb": "EDID 详细信息视频模式兼容性。默认设置在大多数情况下有效,但某些独特的 UEFI/BIOS 可能需要调整。",

View File

@@ -27,6 +27,7 @@ import {useReactAt} from 'i18n-auto-extractor/react'
import AutoHeight from "@/components/AutoHeight";
import { Tabs } from "@/components/Tabs";
export interface TailScaleResponse {
@@ -56,6 +57,26 @@ export interface EasyTierResponse {
node: string;
}
export interface VntRunningResponse {
running: boolean;
}
export interface VntResponse {
config_mode: string;
token: string;
device_id: string;
name: string;
server_addr: string;
config_file: string;
model?: string;
password?: string;
}
export interface CloudflaredRunningResponse {
running: boolean;
}
export interface TLSState {
mode: "self-signed" | "custom" | "disabled";
@@ -127,6 +148,36 @@ export default function SettingsAccessIndexRoute() {
node: "",
});
const [vntConfigMode, setVntConfigMode] = useState("params"); // "params" or "file"
const [tempVntToken, setTempVntToken] = useState("");
const [tempVntDeviceId, setTempVntDeviceId] = useState("");
const [tempVntName, setTempVntName] = useState("");
const [tempVntServerAddr, setTempVntServerAddr] = useState("");
const [vntConfigFileContent, setVntConfigFileContent] = useState("");
const [vntRunningStatus, setVntRunningStatus] = useState<VntRunningResponse>({ running: false });
const [showVntLogModal, setShowVntLogModal] = useState(false);
const [showVntInfoModal, setShowVntInfoModal] = useState(false);
const [vntLog, setVntLog] = useState<string>("");
const [vntInfo, setVntInfo] = useState<string>("");
const [vntConfig, setVntConfig] = useState<VntResponse>({
config_mode: "params",
token: "",
device_id: "",
name: "",
server_addr: "",
config_file: "",
model: "",
password: "",
});
const [tempVntModel, setTempVntModel] = useState("aes_gcm");
const [tempVntPassword, setTempVntPassword] = useState("");
// Cloudflare Tunnel
const [cloudflaredRunningStatus, setCloudflaredRunningStatus] = useState<CloudflaredRunningResponse>({ running: false });
const [cloudflaredToken, setCloudflaredToken] = useState("");
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => {
@@ -162,6 +213,59 @@ export default function SettingsAccessIndexRoute() {
[send],
);
const getCloudflaredStatus = useCallback(() => {
send("getCloudflaredStatus", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get Cloudflare status: ${resp.error.data || "Unknown error"}`);
return;
}
setCloudflaredRunningStatus(resp.result as CloudflaredRunningResponse);
});
}, [send]);
const handleStartCloudflared = useCallback(() => {
if (!cloudflaredToken) {
notifications.error("Please enter Cloudflare Tunnel Token");
return;
}
send("startCloudflared", { token: cloudflaredToken }, resp => {
if ("error" in resp) {
notifications.error(`Failed to start Cloudflare: ${resp.error.data || "Unknown error"}`);
setCloudflaredRunningStatus({ running: false });
return;
}
notifications.success("Cloudflare started");
setCloudflaredRunningStatus({ running: true });
});
}, [send, cloudflaredToken]);
const handleStopCloudflared = useCallback(() => {
send("stopCloudflared", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to stop Cloudflare: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success("Cloudflare stopped");
setCloudflaredRunningStatus({ running: false });
});
}, [send]);
const handleGetCloudflaredLog = useCallback(() => {
send("getCloudflaredLog", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get Cloudflare log: ${resp.error.data || "Unknown error"}`);
setCloudflaredLog("");
return;
}
setCloudflaredLog(resp.result as string);
setShowCloudflaredLogModal(true);
});
}, [send]);
useEffect(() => {
getCloudflaredStatus();
}, [getCloudflaredStatus]);
// Handle TLS mode change
const handleTlsModeChange = (value: string) => {
setTlsMode(value);
@@ -484,6 +588,177 @@ export default function SettingsAccessIndexRoute() {
}
}, [tempEasyTierNetworkNodeMode]);
const handleStartVnt = useCallback(() => {
if (vntConfigMode === "file") {
if (!vntConfigFileContent) {
notifications.error("Please enter Vnt config file content");
return;
}
setVntConfig({
config_mode: "file",
token: "",
device_id: "",
name: "",
server_addr: "",
config_file: vntConfigFileContent,
});
send("startVnt", {
config_mode: "file",
token: "",
device_id: "",
name: "",
server_addr: "",
config_file: vntConfigFileContent,
model: tempVntModel,
password: tempVntPassword,
}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to start Vnt: ${resp.error.data || "Unknown error"}`,
);
setVntRunningStatus({ running: false });
return;
}
notifications.success("Vnt started");
setVntRunningStatus({ running: true });
});
} else {
if (!tempVntToken) {
notifications.error("Please enter Vnt token");
return;
}
setVntConfig({
config_mode: "params",
token: tempVntToken,
device_id: tempVntDeviceId,
name: tempVntName,
server_addr: tempVntServerAddr,
config_file: "",
model: tempVntModel,
password: tempVntPassword,
});
send("startVnt", {
config_mode: "params",
token: tempVntToken,
device_id: tempVntDeviceId,
name: tempVntName,
server_addr: tempVntServerAddr,
config_file: "",
model: tempVntModel,
password: tempVntPassword,
}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to start Vnt: ${resp.error.data || "Unknown error"}`,
);
setVntRunningStatus({ running: false });
return;
}
notifications.success("Vnt started");
setVntRunningStatus({ running: true });
});
}
}, [send, vntConfigMode, tempVntToken, tempVntDeviceId, tempVntName, tempVntServerAddr, vntConfigFileContent, tempVntModel, tempVntPassword]);
const handleStopVnt = useCallback(() => {
send("stopVnt", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to stop Vnt: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Vnt stopped");
setVntRunningStatus({ running: false });
});
}, [send]);
const handleGetVntLog = useCallback(() => {
send("getVntLog", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get Vnt log: ${resp.error.data || "Unknown error"}`,
);
setVntLog("");
return;
}
setVntLog(resp.result as string);
setShowVntLogModal(true);
});
}, [send]);
const handleGetVntInfo = useCallback(() => {
send("getVntInfo", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get Vnt Info: ${resp.error.data || "Unknown error"}`,
);
setVntInfo("");
return;
}
setVntInfo(resp.result as string);
setShowVntInfoModal(true);
});
}, [send]);
const getVntConfig = useCallback(() => {
send("getVntConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get Vnt config: ${resp.error.data || "Unknown error"}`,
);
return;
}
const result = resp.result as VntResponse;
setVntConfig({
config_mode: result.config_mode || "params",
token: result.token,
device_id: result.device_id,
name: result.name,
server_addr: result.server_addr,
config_file: result.config_file,
model: result.model || "",
password: result.password || "",
});
setVntConfigMode(result.config_mode || "params");
if (result.config_file) {
setVntConfigFileContent(result.config_file);
}
if (result.model) setTempVntModel(result.model);
if (result.password) setTempVntPassword(result.password);
});
}, [send]);
const getVntConfigFile = useCallback(() => {
send("getVntConfigFile", {}, resp => {
if ("error" in resp) {
return;
}
const result = resp.result as string;
if (result) {
setVntConfigFileContent(result);
}
});
}, [send]);
const getVntStatus = useCallback(() => {
send("getVntStatus", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get Vnt status: ${resp.error.data || "Unknown error"}`,
);
return;
}
setVntRunningStatus(resp.result as VntRunningResponse);
});
}, [send]);
useEffect(() => {
getVntConfig();
getVntStatus();
getVntConfigFile();
}, [getVntStatus, getVntConfig, getVntConfigFile]);
return (
<div className="space-y-4">
@@ -615,25 +890,31 @@ export default function SettingsAccessIndexRoute() {
description={$at("Manage the mode of Remote access to the device")}
/>
<div className="space-y-4">
{/* Add TailScale settings item */}
<SettingsItem
title="TailScale"
badge="Experimental"
description={$at("Connect to TailScale VPN network")}
>
</SettingsItem>
</div>
<Tabs
defaultTab="tailscale"
tabs={[
{
id: "tailscale",
label: "TailScale",
content: (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
<SettingsItem
title=""
description={$at("TailScale use xEdge server")}
>
<Checkbox disabled={tailScaleConnectionState !== "disconnected"}
{/* Experimental Badge */}
<div>
<span className="inline-flex items-center rounded border border-red-500 px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400">
Experimental
</span>
</div>
{/* TailScale use xEdge server - checkbox on the right */}
<div className="flex items-center justify-between">
<span className="text-sm text-slate-700 dark:text-slate-300">
{$at("TailScale use xEdge server")}
</span>
<Checkbox
disabled={tailScaleConnectionState !== "disconnected"}
checked={tailScaleXEdge}
onChange={e => {
if (tailScaleConnectionState !== "disconnected") {
@@ -643,7 +924,7 @@ export default function SettingsAccessIndexRoute() {
handleTailScaleXEdgeChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
{tailScaleConnectionState === "connecting" && (
<div className="flex items-center justify-between gap-x-2">
@@ -656,6 +937,7 @@ export default function SettingsAccessIndexRoute() {
/>
</div>
)}
{tailScaleConnectionState === "connected" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
@@ -667,43 +949,40 @@ export default function SettingsAccessIndexRoute() {
)}
<Button
size="SM"
theme="light"
text= { isDisconnecting ? $at("Quitting...") : $at("Quit")}
theme="danger"
text={isDisconnecting ? $at("Quitting...") : $at("Quit")}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
disabled={isDisconnecting === true}
/>
</div>
</div>
)}
{tailScaleConnectionState === "logined" && (
<div className="space-y-4">
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network IP")}
{/* IP and Quit button on the same line */}
<div className="flex items-center justify-between">
<span className="text-sm text-slate-700 dark:text-slate-300">
IP: {tailScaleIP}
</span>
<span className="text-right text-sm font-medium">
{tailScaleIP}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
text= { isDisconnecting ? $at("Quitting...") : $at("Quit")}
theme="danger"
text={isDisconnecting ? $at("Quitting...") : $at("Quit")}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
disabled={isDisconnecting === true}
/>
</div>
</div>
</div>
)}
{tailScaleConnectionState === "closed" && (
<div className="text-sm text-red-600 dark:text-red-400">
<p>Connect fail, please retry</p>
</div>
)}
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
{((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
@@ -715,26 +994,29 @@ export default function SettingsAccessIndexRoute() {
</div>
</GridCard>
</AutoHeight>
<div className="space-y-4">
{/* Add ZeroTier settings item */}
<SettingsItem
title="ZeroTier"
badge="Experimental"
description={$at("Connect to ZeroTier VPN network")}
>
</SettingsItem>
</div>
),
},
{
id: "zerotier",
label: "ZeroTier",
content: (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
{/* Experimental Badge */}
<div>
<span className="inline-flex items-center rounded border border-red-500 px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400">
Experimental
</span>
</div>
{zeroTierConnectionState === "connecting" && (
<div className="text-sm text-slate-700 dark:text-slate-300">
<p>{$at("Connecting...")}</p>
</div>
)}
{zeroTierConnectionState === "connected" && (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
@@ -748,13 +1030,14 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
theme="danger"
text={$at("Quit")}
onClick={handleZeroTierLogout}
/>
</div>
</div>
)}
{zeroTierConnectionState === "logined" && (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
@@ -776,13 +1059,14 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
theme="danger"
text={$at("Quit")}
onClick={handleZeroTierLogout}
/>
</div>
</div>
)}
{zeroTierConnectionState === "closed" && (
<div className="flex items-center gap-x-2 justify-between">
<p>{$at("Connect fail, please retry")}</p>
@@ -794,6 +1078,7 @@ export default function SettingsAccessIndexRoute() {
/>
</div>
)}
{(zeroTierConnectionState === "disconnected") && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
@@ -815,20 +1100,26 @@ export default function SettingsAccessIndexRoute() {
</div>
</GridCard>
</AutoHeight>
<div className="space-y-4">
<SettingsItem
title="EasyTier"
description={$at("Connect to EasyTier server")}
/>
</div>
),
},
{
id: "easytier",
label: "EasyTier",
content: (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
{ easyTierRunningStatus.running ? (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network Node")}
</span>
<span className="text-right text-sm font-medium">
{easyTierConfig.node || tempEasyTierNetworkNode}
</span>
</div>
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network Name")}
@@ -845,14 +1136,6 @@ export default function SettingsAccessIndexRoute() {
{easyTierConfig.secret || tempEasyTierNetworkSecret}
</span>
</div>
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network Node")}
</span>
<span className="text-right text-sm font-medium">
{easyTierConfig.node || tempEasyTierNetworkNode}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
@@ -877,6 +1160,33 @@ export default function SettingsAccessIndexRoute() {
</div>
) : (
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title={$at("Network Node")}
description=""
>
<SelectMenuBasic
size="SM"
value={tempEasyTierNetworkNodeMode}
onChange={e => setTempEasyTierNetworkNodeMode(e.target.value)}
options={[
{ value: "default", label: $at("Default") },
{ value: "custom", label: $at("Custom") },
]}
/>
</SettingsItem>
</div>
{tempEasyTierNetworkNodeMode === "custom" && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Network Node")}
value={tempEasyTierNetworkNode}
onChange={e => setTempEasyTierNetworkNode(e.target.value)}
placeholder={$at("Enter EasyTier Network Node")}
/>
</div>
)}
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
@@ -896,35 +1206,6 @@ export default function SettingsAccessIndexRoute() {
/>
</div>
<div className="space-y-4">
<SettingsItem
title={$at("Network Node")}
description=""
>
<SelectMenuBasic
size="SM"
value={tempEasyTierNetworkNodeMode}
onChange={e => setTempEasyTierNetworkNodeMode(e.target.value)}
options={[
{ value: "default", label: $at("Default") },
{ value: "custom", label: $at("Custom") },
]}
/>
</SettingsItem>
</div>
{tempEasyTierNetworkNodeMode === "custom" && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Network Node")}
value={tempEasyTierNetworkNode}
onChange={e => setTempEasyTierNetworkNode(e.target.value)}
placeholder={$at("Enter EasyTier Network Node")}
/>
</div>
)}
<div className="flex items-center gap-x-2">
<Button
size="SM"
@@ -936,20 +1217,280 @@ export default function SettingsAccessIndexRoute() {
</div>
)}
</div>
</div>
</GridCard>
</AutoHeight>
),
},
{
id: "vnt",
label: "Vnt",
content: (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
{ vntRunningStatus.running ? (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Config Mode")}
</span>
<span className="text-right text-sm font-medium">
{vntConfig.config_mode === "file" ? $at("Config File") : $at("Parameters")}
</span>
</div>
{vntConfig.config_mode === "file" ? (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Config")}
</span>
<span className="text-right text-sm font-medium">
{$at("Using config file")}
</span>
</div>
) : (
<>
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Token")}
</span>
<span className="text-right text-sm font-medium">
{vntConfig.token || tempVntToken}
</span>
</div>
{(vntConfig.device_id || tempVntDeviceId) && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Device ID")}
</span>
<span className="text-right text-sm font-medium">
{vntConfig.device_id || tempVntDeviceId}
</span>
</div>
)}
{(vntConfig.name || tempVntName) && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Name")}
</span>
<span className="text-right text-sm font-medium">
{vntConfig.name || tempVntName}
</span>
</div>
)}
{(vntConfig.server_addr || tempVntServerAddr) && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Server Address")}
</span>
<span className="text-right text-sm font-medium">
{vntConfig.server_addr || tempVntServerAddr}
</span>
</div>
)}
</>
)}
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text={$at("Stop")}
onClick={handleStopVnt}
/>
<Button
size="SM"
theme="light"
text={$at("Log")}
onClick={handleGetVntLog}
/>
<Button
size="SM"
theme="light"
text={$at("Info")}
onClick={handleGetVntInfo}
/>
</div>
</div>
) : (
<div className="space-y-4">
{/* Config Mode Selector */}
<div className="space-y-4">
<SettingsItem
title="Frp"
description={$at("Connect to Frp server")}
title={$at("Config Mode")}
description=""
>
<SelectMenuBasic
size="SM"
value={vntConfigMode}
onChange={e => setVntConfigMode(e.target.value)}
options={[
{ value: "params", label: $at("Parameters") },
{ value: "file", label: $at("Config File") },
]}
/>
</SettingsItem>
</div>
{vntConfigMode === "file" ? (
// Config File Mode
<div className="space-y-4">
<TextAreaWithLabel
label={$at("Edit vnt.ini")}
placeholder={$at("Enter vnt-cli configuration")}
value={vntConfigFileContent || ""}
rows={5}
onChange={e => setVntConfigFileContent(e.target.value)}
/>
</div>
) : (
// Parameters Mode
<div className="space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Token (Required)")}
value={tempVntToken}
onChange={e => setTempVntToken(e.target.value)}
placeholder={$at("Enter Vnt Token")}
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Device ID (Optional)")}
value={tempVntDeviceId}
onChange={e => setTempVntDeviceId(e.target.value)}
placeholder={$at("Enter Device ID")}
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Name (Optional)")}
value={tempVntName}
onChange={e => setTempVntName(e.target.value)}
placeholder={$at("Enter Device Name")}
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Server Address (Optional)")}
value={tempVntServerAddr}
onChange={e => setTempVntServerAddr(e.target.value)}
placeholder={$at("Enter Server Address")}
/>
</div>
<div className="space-y-4">
<SettingsItem
title={$at("Encryption Algorithm")}
description=""
>
<SelectMenuBasic
size="SM"
value={tempVntModel}
onChange={e => setTempVntModel(e.target.value)}
options={[
{ value: "aes_gcm", label: "aes_gcm" },
{ value: "chacha20_poly1305", label: "chacha20_poly1305" },
{ value: "chacha20", label: "chacha20" },
{ value: "aes_cbc", label: "aes_cbc" },
{ value: "aes_ecb", label: "aes_ecb" },
{ value: "sm4_cbc", label: "sm4_cbc" },
{ value: "xor", label: "xor" },
]}
/>
</SettingsItem>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
type="password"
label={$at("Password(Optional)")}
value={tempVntPassword}
onChange={e => setTempVntPassword(e.target.value)}
placeholder={$at("Enter Vnt Password")}
/>
</div>
</div>
)}
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={$at("Start")}
onClick={handleStartVnt}
/>
</div>
</div>
)}
</div>
</div>
</GridCard>
</AutoHeight>
),
},
{
id: "cloudflare",
label: "Cloudflare",
content: (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
{cloudflaredRunningStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text={$at("Stop")}
onClick={handleStopCloudflared}
/>
<Button
size="SM"
theme="light"
text={$at("Log")}
onClick={handleGetCloudflaredLog}
/>
</div>
) : (
<>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
type="text"
label={$at("Cloudflare Tunnel Token")}
value={cloudflaredToken}
onChange={e => setCloudflaredToken(e.target.value)}
placeholder={$at("Enter Cloudflare Tunnel Token")}
/>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={$at("Start")}
onClick={handleStartCloudflared}
/>
</div>
</>
)}
</div>
</div>
</GridCard>
</AutoHeight>
),
},
{
id: "frp",
label: "Frp",
content: (
<AutoHeight>
<GridCard>
<div className="p-4">
@@ -963,7 +1504,7 @@ export default function SettingsAccessIndexRoute() {
onChange={e => setFrpcToml(e.target.value)}
/>
<div className="flex items-center gap-x-2">
{ frpcRunningStatus.running ? (
{frpcRunningStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
size="SM"
@@ -991,9 +1532,22 @@ export default function SettingsAccessIndexRoute() {
</div>
</GridCard>
</AutoHeight>
),
},
]}
/>
</div>
<LogDialog
open={showCloudflaredLogModal}
onClose={() => {
setShowCloudflaredLogModal(false);
}}
title="Cloudflare Log"
description={cloudflaredLog}
/>
<LogDialog
open={showEasyTierLogModal}
onClose={() => {
@@ -1021,6 +1575,24 @@ export default function SettingsAccessIndexRoute() {
description={frpcLog}
/>
<LogDialog
open={showVntLogModal}
onClose={() => {
setShowVntLogModal(false);
}}
title="Vnt Log"
description={vntLog}
/>
<LogDialog
open={showVntInfoModal}
onClose={() => {
setShowVntInfoModal(false);
}}
title="Vnt Info"
description={vntInfo}
/>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import Checkbox from "../components/Checkbox";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { TextAreaWithLabel } from "../components/TextArea";
import { useSettingsStore } from "../hooks/stores";
import { useSettingsStore, useHidStore } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { isOnDevice } from "../main";
import notifications from "../notifications";
@@ -24,9 +24,12 @@ export default function SettingsAdvancedRoute() {
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const settings = useSettingsStore();
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
useEffect(() => {
send("getSSHKeyState", {}, resp => {
@@ -209,6 +212,47 @@ export default function SettingsAdvancedRoute() {
/>
</SettingsItem>
<SettingsItem
title={$at("USB Gadget Reinitialize")}
description={$at("Reinitialize USB gadget configuration")}
>
<Button
size="SM"
theme="light"
text={$at("Reinitialize USB Gadget")}
disabled={isReinitializingGadget}
loading={isReinitializingGadget}
onClick={() => {
if (isReinitializingGadget) return;
setIsReinitializingGadget(true);
send("reinitializeUsbGadget", {}, resp => {
setIsReinitializingGadget(false);
if ("error" in resp) {
notifications.error(
`Failed to reinitialize USB gadget: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("USB gadget reinitialized successfully");
});
}}
/>
</SettingsItem>
<SettingsItem
title={$at("Reboot System")}
description={$at("Restart the device system")}
>
<Button
size="SM"
theme="danger"
text={$at("Reboot")}
onClick={() => {
setShowRebootConfirm(true);
}}
/>
</SettingsItem>
<SettingsItem
title={$at("Reset Configuration")}
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
@@ -250,6 +294,39 @@ export default function SettingsAdvancedRoute() {
confirmText="I Understand, Enable Anyway"
onConfirm={confirmLoopbackModeEnable}
/>
<ConfirmDialog
open={showRebootConfirm}
onClose={() => {
setShowRebootConfirm(false);
}}
title={$at("Reboot System?")}
description={
<>
<p>
{$at("Are you sure you want to reboot the system?")}
</p>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
{$at("The device will restart and you will be disconnected from the web interface.")}
</p>
</>
}
variant="warning"
cancelText={$at("Cancel")}
confirmText={$at("Reboot")}
onConfirm={() => {
setShowRebootConfirm(false);
send("reboot", { force: false }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reboot: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("System rebooting...");
});
}}
/>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { LuEthernetPort } from "react-icons/lu";
import {
IPv4Mode,
IPv4StaticConfig,
IPv6Mode,
LLDPMode,
mDNSMode,
@@ -84,11 +86,25 @@ export default function SettingsNetworkRoute() {
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
// We use this to indicate whether saved settings differ from initial (effective) settings
const initialNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const [customDomain, setCustomDomain] = useState<string>("");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("local");
const { id } = useParams();
const baselineKey = id ? `network_baseline_${id}` : "network_baseline";
const baselineResetKey = id ? `network_baseline_reset_${id}` : "network_baseline_reset";
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.get("networkChanged") === "true") {
localStorage.setItem(baselineResetKey, "1");
url.searchParams.delete("networkChanged");
window.history.replaceState(null, "", url.toString());
}
}, [baselineResetKey]);
useEffect(() => {
if (networkSettings.domain && networkSettingsLoaded) {
@@ -109,10 +125,33 @@ export default function SettingsNetworkRoute() {
if ("error" in resp) return;
console.log(resp.result);
setNetworkSettings(resp.result as NetworkSettings);
if (!firstNetworkSettings.current) {
firstNetworkSettings.current = resp.result as NetworkSettings;
}
const resetFlag = localStorage.getItem(baselineResetKey);
const stored = localStorage.getItem(baselineKey);
if (resetFlag) {
initialNetworkSettings.current = resp.result as NetworkSettings;
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
localStorage.removeItem(baselineResetKey);
} else if (stored) {
try {
const parsed = JSON.parse(stored) as NetworkSettings;
const server = resp.result as NetworkSettings;
if (JSON.stringify(parsed) !== JSON.stringify(server)) {
initialNetworkSettings.current = server;
localStorage.setItem(baselineKey, JSON.stringify(server));
} else {
initialNetworkSettings.current = parsed;
}
} catch {
initialNetworkSettings.current = resp.result as NetworkSettings;
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
}
} else {
initialNetworkSettings.current = resp.result as NetworkSettings;
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
}
setNetworkSettingsLoaded(true);
});
}, [send]);
@@ -164,7 +203,41 @@ export default function SettingsNetworkRoute() {
}, [getNetworkState, getNetworkSettings]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode });
const newMode = value as IPv4Mode;
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
// Initialize static config if switching to static mode
if (newMode === "static" && !updatedSettings.ipv4_static) {
updatedSettings.ipv4_static = {
address: "",
netmask: "",
gateway: "",
dns: [],
};
}
setNetworkSettings(updatedSettings);
};
const handleIpv4RequestAddressChange = (value: string) => {
setNetworkSettings({ ...networkSettings, ipv4_request_address: value });
};
const handleIpv4StaticChange = (field: keyof IPv4StaticConfig, value: string | string[]) => {
const staticConfig = networkSettings.ipv4_static || {
address: "",
netmask: "",
gateway: "",
dns: [],
};
setNetworkSettings({
...networkSettings,
ipv4_static: {
...staticConfig,
[field]: value,
},
});
};
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
@@ -212,6 +285,55 @@ export default function SettingsNetworkRoute() {
);
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
const [applyingRequestAddr, setApplyingRequestAddr] = useState(false);
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
const isIPv4StaticEqual = (a?: IPv4StaticConfig, b?: IPv4StaticConfig) => {
const na = a || { address: "", netmask: "", gateway: "", dns: [] };
const nb = b || { address: "", netmask: "", gateway: "", dns: [] };
const adns = (na.dns || []).map(x => x.trim()).filter(x => x.length > 0).sort();
const bdns = (nb.dns || []).map(x => x.trim()).filter(x => x.length > 0).sort();
if ((na.address || "").trim() !== (nb.address || "").trim()) return false;
if ((na.netmask || "").trim() !== (nb.netmask || "").trim()) return false;
if ((na.gateway || "").trim() !== (nb.gateway || "").trim()) return false;
if (adns.length !== bdns.length) return false;
for (let i = 0; i < adns.length; i++) {
if (adns[i] !== bdns[i]) return false;
}
return true;
};
const handleApplyRequestAddress = useCallback(() => {
const requested = (networkSettings.ipv4_request_address || "").trim();
if (!requested) {
notifications.error("Please enter a valid Request Address");
return;
}
if (networkSettings.ipv4_mode !== "dhcp") {
notifications.error("Request Address is only available in DHCP mode");
return;
}
setApplyingRequestAddr(true);
send("setNetworkSettings", { settings: networkSettings }, resp => {
if ("error" in resp) {
setApplyingRequestAddr(false);
return notifications.error(
"Failed to save Request Address: " + (resp.error.data ? resp.error.data : resp.error.message),
);
}
setApplyingRequestAddr(false);
notifications.success("Request Address saved. Changes will take effect after restart.");
});
}, [networkSettings, send]);
useEffect(() => {
const dns = (networkSettings.ipv4_static?.dns || []).join(", ");
setIpv4StaticDnsText(dns);
}, [networkSettings.ipv4_static?.dns]);
return (
<>
@@ -337,7 +459,10 @@ export default function SettingsNetworkRoute() {
<Button
size="SM"
theme="primary"
disabled={firstNetworkSettings.current === networkSettings}
disabled={
firstNetworkSettings.current === networkSettings ||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
}
text={$at("Save settings")}
onClick={() => setNetworkSettingsRemote(networkSettings)}
/>
@@ -346,17 +471,105 @@ export default function SettingsNetworkRoute() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="space-y-4">
<SettingsItem title={$at("IPv4 Mode")} description={$at("Configure IPv4 mode")}>
<SettingsItem
title={$at("IPv4 Mode")}
description={$at("Configure IPv4 mode")}
// badge={networkSettings.pending_reboot ? $at("Effective Upon Reboot") : undefined}
>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv4_mode}
onChange={e => handleIpv4ModeChange(e.target.value)}
onChange={e => {
const next = e.target.value as IPv4Mode;
setPendingIpv4Mode(next);
setShowIpv4RestartConfirm(true);
}}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
// { value: "static", label: "Static" },
{ value: "static", label: "Static" },
])}
/>
</SettingsItem>
{networkSettings.ipv4_mode === "dhcp" && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
type="text"
label={$at("Request Address")}
placeholder="192.168.1.100"
value={networkSettings.ipv4_request_address || ""}
onChange={e => {
handleIpv4RequestAddressChange(e.target.value);
}}
/>
<Button
size="SM"
theme="primary"
text={$at("Save")}
disabled={applyingRequestAddr || !networkSettings.ipv4_request_address}
onClick={() => setShowRequestAddrConfirm(true)}
/>
</div>
)}
{networkSettings.ipv4_mode === "static" && (
<AutoHeight>
<GridCard>
<div className="p-4 mt-1 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 items-end gap-x-2">
<InputFieldWithLabel
size="SM"
type="text"
label={$at("IP Address")}
placeholder="192.168.1.100"
value={networkSettings.ipv4_static?.address || ""}
onChange={e => {
handleIpv4StaticChange("address", e.target.value);
}}
/>
<InputFieldWithLabel
size="SM"
type="text"
label={$at("Netmask")}
placeholder="255.255.255.0"
value={networkSettings.ipv4_static?.netmask || ""}
onChange={e => {
handleIpv4StaticChange("netmask", e.target.value);
}}
/>
<InputFieldWithLabel
size="SM"
type="text"
label={$at("Gateway")}
placeholder="192.168.1.1"
value={networkSettings.ipv4_static?.gateway || ""}
onChange={e => {
handleIpv4StaticChange("gateway", e.target.value);
}}
/>
<InputFieldWithLabel
size="SM"
type="text"
label={$at("DNS Servers")}
placeholder="8.8.8.8,8.8.4.4"
value={ipv4StaticDnsText}
onChange={e => setIpv4StaticDnsText(e.target.value)}
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={$at("Save")}
onClick={() => setShowApplyStaticConfirm(true)}
/>
</div>
</div>
</GridCard>
</AutoHeight>
)}
{networkSettings.ipv4_mode === "dhcp" && (
<AutoHeight>
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
<GridCard>
@@ -386,6 +599,7 @@ export default function SettingsNetworkRoute() {
/>
)}
</AutoHeight>
)}
</div>
<div className="space-y-4">
<SettingsItem title={$at("IPv6 Mode")} description={$at("Configure the IPv6 mode")}>
@@ -453,7 +667,7 @@ export default function SettingsNetworkRoute() {
open={showRenewLeaseConfirm}
onClose={() => setShowRenewLeaseConfirm(false)}
title={$at("Renew DHCP Lease")}
description={$at("This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.")}
description={$at("Changes will take effect after a restart.")}
variant="danger"
confirmText={$at("Renew DHCP Lease")}
cancelText={$at("Cancel")}
@@ -462,6 +676,64 @@ export default function SettingsNetworkRoute() {
setShowRenewLeaseConfirm(false);
}}
/>
<ConfirmDialog
open={showApplyStaticConfirm}
onClose={() => setShowApplyStaticConfirm(false)}
title={$at("Save Static IPv4 Settings?")}
description={$at("Changes will take effect after a restart.")}
variant="warning"
confirmText={$at("Confirm")}
cancelText={$at("Cancel")}
onConfirm={() => {
setShowApplyStaticConfirm(false);
const dnsArray = ipv4StaticDnsText
.split(",")
.map(d => d.trim())
.filter(d => d.length > 0);
const updatedSettings: NetworkSettings = {
...networkSettings,
ipv4_static: {
...(networkSettings.ipv4_static || { address: "", netmask: "", gateway: "", dns: [] }),
dns: dnsArray,
},
};
setNetworkSettingsRemote(updatedSettings);
}}
/>
<ConfirmDialog
open={showRequestAddrConfirm}
onClose={() => setShowRequestAddrConfirm(false)}
title={$at("Save Request Address?")}
description={$at("This will save the requested IPv4 address. Changes take effect after a restart.")}
variant="warning"
confirmText={$at("Save")}
cancelText={$at("Cancel")}
onConfirm={() => {
setShowRequestAddrConfirm(false);
handleApplyRequestAddress();
}}
/>
<ConfirmDialog
open={showIpv4RestartConfirm}
onClose={() => setShowIpv4RestartConfirm(false)}
title={$at("Change IPv4 Mode?")}
description={$at("IPv4 mode changes will take effect after a restart.")}
variant="warning"
confirmText={$at("Confirm")}
cancelText={$at("Cancel")}
onConfirm={() => {
setShowIpv4RestartConfirm(false);
if (pendingIpv4Mode) {
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: pendingIpv4Mode };
if (pendingIpv4Mode === "static" && !updatedSettings.ipv4_static) {
updatedSettings.ipv4_static = { address: "", netmask: "", gateway: "", dns: [] };
}
setNetworkSettings(updatedSettings);
setNetworkSettingsRemote(updatedSettings);
setPendingIpv4Mode(null);
}
}}
/>
</>
);
}

View File

@@ -8,6 +8,7 @@ import { useSettingsStore } from "@/hooks/stores";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import Checkbox from "../components/Checkbox";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
@@ -48,6 +49,7 @@ export default function SettingsVideoRoute() {
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
const [forceHpd, setForceHpd] = useState(false);
// Video enhancement settings from store
const videoSaturation = useSettingsStore(state => state.videoSaturation);
@@ -85,8 +87,32 @@ export default function SettingsVideoRoute() {
setCustomEdidValue(receivedEdid);
}
});
send("getForceHpd", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get force EDID output: ${resp.error.data || "Unknown error"}`);
setForceHpd(false);
return;
}
setForceHpd(resp.result as boolean);
});
}, [send]);
const handleForceHpdChange = (checked: boolean) => {
send("setForceHpd", { forceHpd: checked }, resp => { // 修复参数名称为forceHpd
if ("error" in resp) {
notifications.error(`Failed to set force EDID output: ${resp.error.data || "Unknown error"}`);
setForceHpd(!checked);
return;
}
notifications.success(`Force EDID output ${checked ? "enabled" : "disabled"}`);
setForceHpd(checked);
});
};
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
@@ -205,6 +231,17 @@ export default function SettingsVideoRoute() {
</div>
</div>
{/* EDID Force Output Setting */}
<SettingsItem
title={$at("Force EDID Output")}
description={$at("Force EDID output even when no display is connected")}
>
<Checkbox
checked={forceHpd}
onChange={e => handleForceHpdChange(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title="EDID"
description={$at("Adjust the EDID settings for the display")}

View File

@@ -566,12 +566,13 @@ export default function KvmIdRoute() {
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
const setUsbState = useHidStore(state => state.setUsbState);
const usbState = useHidStore(state => state.usbState);
const setHdmiState = useVideoStore(state => state.setHdmiState);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@@ -601,6 +602,42 @@ export default function KvmIdRoute() {
setKeyboardLedStateSyncAvailable(true);
}
if (resp.method === "hidDeviceMissing") {
const params = resp.params as { device: string; error: string };
console.error("HID device missing:", params);
send("getUsbEmulationState", {}, stateResp => {
if ("error" in stateResp) return;
const emuEnabled = stateResp.result as boolean;
if (!emuEnabled || usbState !== "configured") {
return;
}
setIsReinitializingGadget(true);
notifications.error(
`USB HID device (${params.device}) is missing. Reinitializing USB gadget...`,
{ duration: 5000 }
);
send("reinitializeUsbGadgetSoft", {}, (resp) => {
setIsReinitializingGadget(false);
if ("error" in resp) {
notifications.error(
`Failed to reinitialize USB gadget (soft): ${resp.error.message}`,
{ duration: 5000 }
);
} else {
notifications.success(
"USB gadget soft reinitialized successfully",
{ duration: 3000 }
);
}
});
});
}
if (resp.method === "otaState") {
const otaState = resp.params as UpdateState["otaState"];
setOtaState(otaState);
@@ -624,6 +661,12 @@ export default function KvmIdRoute() {
window.location.href = currentUrl.toString();
}
}
if (resp.method === "refreshPage") {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set("networkChanged", "true");
window.location.href = currentUrl.toString();
}
}
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);

109
usb.go
View File

@@ -1,6 +1,7 @@
package kvm
import (
"fmt"
"os"
"time"
@@ -43,6 +44,21 @@ func initUsbGadget() {
}
})
// Set callback for HID device missing errors
gadget.SetOnHidDeviceMissing(func(device string, err error) {
usbLogger.Error().
Str("device", device).
Err(err).
Msg("HID device missing, sending notification to client")
if currentSession != nil {
writeJSONRPCEvent("hidDeviceMissing", map[string]interface{}{
"device": device,
"error": err.Error(),
}, currentSession)
}
})
// open the keyboard hid file to listen for keyboard events
if err := gadget.OpenKeyboardHidFile(); err != nil {
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
@@ -136,3 +152,96 @@ func rpcSendUsbWakeupSignal() error {
}
return nil
}
// rpcReinitializeUsbGadget reinitializes the USB gadget
func rpcReinitializeUsbGadget() error {
usbLogger.Info().Msg("reinitializing USB gadget (hard)")
if gadget == nil {
return fmt.Errorf("USB gadget not initialized")
}
mediaState, _ := rpcGetVirtualMediaState()
if mediaState != nil && (mediaState.Filename != "" || mediaState.URL != "") {
usbLogger.Info().Interface("mediaState", mediaState).Msg("virtual media mounted, unmounting before USB reinit")
if err := rpcUnmountImage(); err != nil {
usbLogger.Warn().Err(err).Msg("failed to unmount virtual media before USB reinit")
}
}
// Recreate the gadget instance similar to program restart
gadget = usbgadget.NewUsbGadget(
"kvm",
config.UsbDevices,
config.UsbConfig,
usbLogger,
)
// Reapply callbacks
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
if currentSession != nil {
writeJSONRPCEvent("keyboardLedState", state, currentSession)
}
})
gadget.SetOnHidDeviceMissing(func(device string, err error) {
usbLogger.Error().
Str("device", device).
Err(err).
Msg("HID device missing, sending notification to client")
if currentSession != nil {
writeJSONRPCEvent("hidDeviceMissing", map[string]interface{}{
"device": device,
"error": err.Error(),
}, currentSession)
}
})
// Reopen keyboard HID file
if err := gadget.OpenKeyboardHidFile(); err != nil {
usbLogger.Warn().Err(err).Msg("failed to open keyboard hid file after reinit")
}
// Force a USB state update notification
triggerUSBStateUpdate()
initSystemInfo()
usbLogger.Info().Msg("USB gadget reinitialized successfully")
return nil
}
// rpcReinitializeUsbGadgetSoft performs a lightweight refresh:
// reapply configuration and rebind without recreating the gadget instance.
func rpcReinitializeUsbGadgetSoft() error {
usbLogger.Info().Msg("reinitializing USB gadget (soft)")
if gadget == nil {
return fmt.Errorf("USB gadget not initialized")
}
mediaState, _ := rpcGetVirtualMediaState()
if mediaState != nil && (mediaState.Filename != "" || mediaState.URL != "") {
usbLogger.Info().Interface("mediaState", mediaState).Msg("virtual media mounted, unmounting before USB soft reinit")
if err := rpcUnmountImage(); err != nil {
usbLogger.Warn().Err(err).Msg("failed to unmount virtual media before USB soft reinit")
}
}
// Update gadget configuration (will rebind USB inside)
if err := gadget.UpdateGadgetConfig(); err != nil {
usbLogger.Error().Err(err).Msg("failed to soft reinitialize USB gadget")
return fmt.Errorf("failed to soft reinitialize USB gadget: %w", err)
}
// Reopen keyboard HID file
if err := gadget.OpenKeyboardHidFile(); err != nil {
usbLogger.Warn().Err(err).Msg("failed to reopen keyboard hid file after soft reinit")
}
// Force a USB state update notification
triggerUSBStateUpdate()
usbLogger.Info().Msg("USB gadget soft reinitialized successfully")
return nil
}

View File

@@ -997,7 +997,7 @@ func updateMtpWithSDStatus() error {
}
if config.UsbDevices.Mtp {
if err := gadget.UnbindUDC(); err != nil {
if err := gadget.UnbindUDCToDWC3(); err != nil {
logger.Error().Err(err).Msg("failed to unbind UDC")
}

View File

@@ -49,3 +49,8 @@ func HandleVideoStateMessage(event CtrlResponse) {
func rpcGetVideoState() (VideoInputState, error) {
return lastVideoState, nil
}
func setForceHpd() error {
err := rpcSetForceHpd(config.ForceHpd)
return err
}

277
vpn.go
View File

@@ -381,6 +381,95 @@ func rpcGetFrpcStatus() (FrpcStatus, error) {
return FrpcStatus{Running: frpcRunning()}, nil
}
type CloudflaredStatus struct {
Running bool `json:"running"`
}
func cloudflaredRunning() bool {
cmd := exec.Command("pgrep", "-x", "cloudflared")
return cmd.Run() == nil
}
var (
cloudflaredLogPath = "/tmp/cloudflared.log"
)
func rpcStartCloudflared(token string) error {
if cloudflaredRunning() {
_ = exec.Command("pkill", "-x", "cloudflared").Run()
}
if token == "" {
return fmt.Errorf("cloudflared token is empty")
}
cmd := exec.Command("cloudflared", "tunnel", "run", "--token", token)
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer logFile.Close()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start cloudflared failed: %w", err)
}
config.CloudflaredAutoStart = true
config.CloudflaredToken = token
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcStopCloudflared() error {
if cloudflaredRunning() {
err := exec.Command("pkill", "-x", "cloudflared").Run()
if err != nil {
return fmt.Errorf("failed to stop cloudflared: %w", err)
}
}
config.CloudflaredAutoStart = false
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetCloudflaredStatus() (CloudflaredStatus, error) {
return CloudflaredStatus{Running: cloudflaredRunning()}, nil
}
func rpcGetCloudflaredLog() (string, error) {
f, err := os.Open(cloudflaredLogPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("cloudflared log file not exist")
}
return "", err
}
defer f.Close()
const want = 30
lines := make([]string, 0, want+10)
sc := bufio.NewScanner(f)
for sc.Scan() {
lines = append(lines, sc.Text())
if len(lines) > want {
lines = lines[1:]
}
}
if err := sc.Err(); err != nil {
return "", err
}
var buf []byte
for _, l := range lines {
buf = append(buf, l...)
buf = append(buf, '\n')
}
return string(buf), nil
}
type EasytierStatus struct {
Running bool `json:"running"`
}
@@ -504,6 +593,175 @@ func rpcGetEasyTierStatus() (EasytierStatus, error) {
return EasytierStatus{Running: easytierRunning()}, nil
}
type VntStatus struct {
Running bool `json:"running"`
}
var (
vntLogPath = "/tmp/vnt.log"
vntConfigFilePath = "/userdata/vnt/vnt.ini"
)
func vntRunning() bool {
cmd := exec.Command("pgrep", "-x", "vnt-cli")
return cmd.Run() == nil
}
func rpcGetVntLog() (string, error) {
f, err := os.Open(vntLogPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("vnt log file not exist")
}
return "", err
}
defer f.Close()
const want = 30
lines := make([]string, 0, want+10)
sc := bufio.NewScanner(f)
for sc.Scan() {
lines = append(lines, sc.Text())
if len(lines) > want {
lines = lines[1:]
}
}
if err := sc.Err(); err != nil {
return "", err
}
var buf []byte
for _, l := range lines {
buf = append(buf, l...)
buf = append(buf, '\n')
}
return string(buf), nil
}
func rpcGetVntInfo() (string, error) {
cmd := exec.Command("vnt-cli", "--info")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get vnt info: %w", err)
}
return string(output), nil
}
func rpcGetVntConfig() (VntConfig, error) {
return config.VntConfig, nil
}
func rpcGetVntConfigFile() (string, error) {
return config.VntConfig.ConfigFile, nil
}
func rpcStartVnt(configMode, token, deviceId, name, serverAddr, configFile string, model string, password string) error {
if vntRunning() {
_ = exec.Command("pkill", "-x", "vnt-cli").Run()
}
var args []string
if configMode == "file" {
// Use config file mode
if configFile == "" {
return fmt.Errorf("vnt config file is required in file mode")
}
// Save config file
_ = os.MkdirAll(filepath.Dir(vntConfigFilePath), 0700)
if err := os.WriteFile(vntConfigFilePath, []byte(configFile), 0600); err != nil {
return fmt.Errorf("failed to write vnt config file: %w", err)
}
args = []string{"-f", vntConfigFilePath}
} else {
// Use params mode (default)
if token == "" {
return fmt.Errorf("vnt token is required in params mode")
}
args = []string{"-k", token}
if deviceId != "" {
args = append(args, "-d", deviceId)
}
if name != "" {
args = append(args, "-n", name)
}
if serverAddr != "" {
args = append(args, "-s", serverAddr)
}
// Encryption model and password
if model != "" {
args = append(args, "--model", model)
}
if password != "" {
args = append(args, "-w", password)
}
args = append(args, "--compressor", "lz4")
}
cmd := exec.Command("vnt-cli", args...)
cmd.Stdout = nil
cmd.Stderr = nil
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to open vnt log file: %w", err)
}
defer logFile.Close()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start vnt failed: %w", err)
} else {
config.VntAutoStart = true
config.VntConfig = VntConfig{
ConfigMode: configMode,
Token: token,
DeviceId: deviceId,
Name: name,
ServerAddr: serverAddr,
ConfigFile: configFile,
Model: model,
Password: password,
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
return nil
}
func rpcStopVnt() error {
if vntRunning() {
err := exec.Command("pkill", "-x", "vnt-cli").Run()
if err != nil {
return fmt.Errorf("failed to stop vnt: %w", err)
}
}
config.VntAutoStart = false
err := SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetVntStatus() (VntStatus, error) {
return VntStatus{Running: vntRunning()}, nil
}
func initVPN() {
waitVpnCtrlClientConnected()
go func() {
@@ -540,6 +798,25 @@ func initVPN() {
vpnLogger.Error().Err(err).Msg("Failed to auto start easytier")
}
}
if config.VntAutoStart {
if config.VntConfig.ConfigMode == "file" && config.VntConfig.ConfigFile != "" {
if err := rpcStartVnt("file", "", "", "", "", config.VntConfig.ConfigFile, config.VntConfig.Model, config.VntConfig.Password); err != nil {
vpnLogger.Error().Err(err).Msg("Failed to auto start vnt (file mode)")
}
} else if config.VntConfig.Token != "" {
if err := rpcStartVnt("params", config.VntConfig.Token, config.VntConfig.DeviceId, config.VntConfig.Name, config.VntConfig.ServerAddr, "", config.VntConfig.Model, config.VntConfig.Password); err != nil {
vpnLogger.Error().Err(err).Msg("Failed to auto start vnt (params mode)")
}
}
}
if config.CloudflaredAutoStart && config.CloudflaredToken != "" {
if err := rpcStartCloudflared(config.CloudflaredToken); err != nil {
vpnLogger.Error().Err(err).Msg("Failed to auto start cloudflared")
}
}
}()
go func() {