diff --git a/Makefile b/Makefile index 4325575..5121226 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/config.go b/config.go index 5f5dd97..4795962 100644 --- a/config.go +++ b/config.go @@ -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"` @@ -99,15 +100,18 @@ type Config struct { DisplayOffAfterSec int `json:"display_off_after_sec"` TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" UsbConfig *usbgadget.Config `json:"usb_config"` - UsbDevices *usbgadget.Devices `json:"usb_devices"` - NetworkConfig *network.NetworkConfig `json:"network_config"` - DefaultLogLevel string `json:"default_log_level"` + 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"` ZeroTierNetworkID string `json:"zerotier_network_id"` 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 @@ -149,12 +167,14 @@ var defaultConfig = &Config{ Audio: false, //At any given time, only one of Audio and Mtp can be set to true Mtp: false, }, - NetworkConfig: &network.NetworkConfig{}, + NetworkConfig: &network.NetworkConfig{}, + AppliedNetworkConfig: nil, DefaultLogLevel: "INFO", ZeroTierAutoStart: false, TailScaleAutoStart: false, TailScaleXEdge: false, FrpcAutoStart: false, + CloudflaredAutoStart: false, IO0Status: true, IO1Status: true, AudioMode: "disabled", diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index 76102a3..6b043eb 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -345,15 +345,35 @@ func (f *FieldConfig) validateField() error { return nil } - val, err := toString(f.CurrentValue) - if err != nil { - return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err) + 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 + } } - if val == "" { - return nil - } + 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 } diff --git a/internal/confparser/utils.go b/internal/confparser/utils.go index a46871e..e634fee 100644 --- a/internal/confparser/utils.go +++ b/internal/confparser/utils.go @@ -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)) diff --git a/internal/network/config.go b/internal/network/config.go index 090d359..b099421 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -33,23 +33,24 @@ type IPv6StaticConfig struct { DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` } type NetworkConfig struct { - Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` - Domain null.String `json:"domain,omitempty" validate_type:"hostname"` + Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` + Domain null.String `json:"domain,omitempty" validate_type:"hostname"` - IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` - IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"` - IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` + IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` + IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"` + IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` - IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` - IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` + IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` + IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` - LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` - LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` - MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` - TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` - 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"` + LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` + LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` + MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` + TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` + 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 { diff --git a/internal/network/netif.go b/internal/network/netif.go index e16cab0..47141a4 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -1,13 +1,16 @@ package network import ( - "fmt" - "net" - "sync" + "fmt" + "net" + "os" + "strconv" + "strings" + "sync" - "kvm/internal/confparser" - "kvm/internal/logging" - "kvm/internal/udhcpc" + "kvm/internal/confparser" + "kvm/internal/logging" + "kvm/internal/udhcpc" "github.com/rs/zerolog" @@ -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 } @@ -343,6 +371,162 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { } func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { - _ = s.setHostnameIfNotSame() - s.cbConfigChange(config) + _ = 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 } diff --git a/internal/network/rpc.go b/internal/network/rpc.go index ef74b9c..63a602d 100644 --- a/internal/network/rpc.go +++ b/internal/network/rpc.go @@ -1,11 +1,13 @@ package network import ( - "fmt" - "time" + "fmt" + "time" - "kvm/internal/confparser" - "kvm/internal/udhcpc" + "kvm/internal/confparser" + "kvm/internal/udhcpc" + + "github.com/guregu/null/v6" ) type RpcIPv6Address struct { @@ -99,17 +101,22 @@ func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { } func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { - currentSettings := s.config + currentSettings := s.config - err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) - if err != nil { - return err - } + err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) + if err != nil { + return err + } - if IsSame(currentSettings, settings.NetworkConfig) { - // no changes, do nothing - return nil - } + neutralA := *currentSettings + neutralB := settings.NetworkConfig + neutralA.PendingReboot = null.Bool{} + neutralB.PendingReboot = null.Bool{} + + if IsSame(neutralA, neutralB) { + // no changes, do nothing + return nil + } s.config = &settings.NetworkConfig s.onConfigChange(s.config) @@ -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) +} diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 99c2701..cf7401a 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -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 { - c.logger.Info().Msg("link is up, starting udhcpc") - go c.runUDHCPC() + 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 . +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() + } +} diff --git a/internal/usbgadget/changeset.go b/internal/usbgadget/changeset.go index 137b290..0eb0046 100644 --- a/internal/usbgadget/changeset.go +++ b/internal/usbgadget/changeset.go @@ -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 diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 0b1b78d..6c51efc 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -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 +} diff --git a/internal/usbgadget/config_tx.go b/internal/usbgadget/config_tx.go index fa460b8..844893d 100644 --- a/internal/usbgadget/config_tx.go +++ b/internal/usbgadget/config_tx.go @@ -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}, }) } diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 4585784..8e2d86b 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -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) } diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index d05a763..d3bdfda 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -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) } } diff --git a/internal/usbgadget/hid_mouse_relative.go b/internal/usbgadget/hid_mouse_relative.go index 42e9ab1..eff41ef 100644 --- a/internal/usbgadget/hid_mouse_relative.go +++ b/internal/usbgadget/hid_mouse_relative.go @@ -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) } } diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index dcd1b49..2479b3d 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -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 diff --git a/jsonrpc.go b/jsonrpc.go index eb2dc71..a6849f8 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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}, } diff --git a/main.go b/main.go index 94f49f3..42ac58f 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/network.go b/network.go index 620d606..560ee4f 100644 --- a/network.go +++ b/network.go @@ -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) +} diff --git a/ota.go b/ota.go index 11c6997..be2f9f1 100644 --- a/ota.go +++ b/ota.go @@ -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" diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index f5144b8..37d0e94 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -90,7 +90,7 @@ export default function DashboardNavbar({
tab.id === activeTab)?.content; + + return ( +
+ {/* Tab buttons */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Tab content */} +
{activeTabContent}
+
+ ); +} + diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index f16070b..f898f33 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -263,6 +263,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
  • {$at("If using an adapter, ensure it's compatible and functioning correctly")}
  • +
  • + {$at("Certain motherboards do not support simultaneous multi-display output")} +
  • {$at("Ensure source device is not in sleep mode and outputting a signal")}
  • diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index 577c387..d3b0a4e 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -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 (
    )}
    -

    - {$at("Virtual Keyboard")} -

    +
    +

    + {$at("Virtual Keyboard")} +

    +
    +
    + {/* Combination keys */} +
    + + + +
    +
    (null); const audioElm = useRef(null); + const pasteCaptureRef = useRef(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>(new Map()); + const initialPinchDistance = useRef(null); + const initialPinchScale = useRef(1); + const lastPanPoint = useRef<{ x: number; y: number } | null>(null); + const lastTapAt = useRef(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((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, @@ -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,23 +678,20 @@ export default function WebRTCVideo() { ); // Setup Keyboard Events - useEffect( - function setupKeyboardEvents() { - const abortController = new AbortController(); - const signal = abortController.signal; + useEffect(function setupKeyboardEvents() { + const abortController = new AbortController(); + const signal = abortController.signal; - document.addEventListener("keydown", keyDownHandler, { signal }); - document.addEventListener("keyup", keyUpHandler, { signal }); + document.addEventListener("keydown", keyDownHandler, { signal }); + document.addEventListener("keyup", keyUpHandler, { signal }); - window.addEventListener("blur", resetKeyboardState, { signal }); - document.addEventListener("visibilitychange", resetKeyboardState, { signal }); + window.addEventListener("blur", resetKeyboardState, { signal }); + document.addEventListener("visibilitychange", resetKeyboardState, { signal }); - return () => { - abortController.abort(); - }; - }, - [keyDownHandler, keyUpHandler, resetKeyboardState], - ); + return () => { + abortController.abort(); + }; + }, [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(null); + const zoomLayerRef = useRef(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 */}
    -
    +
    +