mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-17 19:22:15 +01:00
Update App version to 0.0.4
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
4
Makefile
4
Makefile
@@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
|||||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.0.3-dev
|
VERSION_DEV ?= 0.0.4-dev
|
||||||
VERSION ?= 0.0.3
|
VERSION ?= 0.0.4
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := kvm
|
KVM_PKG_NAME := kvm
|
||||||
|
|||||||
28
config.go
28
config.go
@@ -92,6 +92,7 @@ type Config struct {
|
|||||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||||
KeyboardLayout string `json:"keyboard_layout"`
|
KeyboardLayout string `json:"keyboard_layout"`
|
||||||
EdidString string `json:"hdmi_edid_string"`
|
EdidString string `json:"hdmi_edid_string"`
|
||||||
|
ForceHpd bool `json:"force_hpd"` // 强制输出EDID
|
||||||
ActiveExtension string `json:"active_extension"`
|
ActiveExtension string `json:"active_extension"`
|
||||||
DisplayRotation string `json:"display_rotation"`
|
DisplayRotation string `json:"display_rotation"`
|
||||||
DisplayMaxBrightness int `json:"display_max_brightness"`
|
DisplayMaxBrightness int `json:"display_max_brightness"`
|
||||||
@@ -99,15 +100,18 @@ type Config struct {
|
|||||||
DisplayOffAfterSec int `json:"display_off_after_sec"`
|
DisplayOffAfterSec int `json:"display_off_after_sec"`
|
||||||
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
|
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
|
||||||
UsbConfig *usbgadget.Config `json:"usb_config"`
|
UsbConfig *usbgadget.Config `json:"usb_config"`
|
||||||
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
||||||
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
||||||
DefaultLogLevel string `json:"default_log_level"`
|
AppliedNetworkConfig *network.NetworkConfig `json:"applied_network_config,omitempty"`
|
||||||
|
DefaultLogLevel string `json:"default_log_level"`
|
||||||
TailScaleAutoStart bool `json:"tailscale_autostart"`
|
TailScaleAutoStart bool `json:"tailscale_autostart"`
|
||||||
TailScaleXEdge bool `json:"tailscale_xedge"`
|
TailScaleXEdge bool `json:"tailscale_xedge"`
|
||||||
ZeroTierNetworkID string `json:"zerotier_network_id"`
|
ZeroTierNetworkID string `json:"zerotier_network_id"`
|
||||||
ZeroTierAutoStart bool `json:"zerotier_autostart"`
|
ZeroTierAutoStart bool `json:"zerotier_autostart"`
|
||||||
FrpcAutoStart bool `json:"frpc_autostart"`
|
FrpcAutoStart bool `json:"frpc_autostart"`
|
||||||
FrpcToml string `json:"frpc_toml"`
|
FrpcToml string `json:"frpc_toml"`
|
||||||
|
CloudflaredAutoStart bool `json:"cloudflared_autostart"`
|
||||||
|
CloudflaredToken string `json:"cloudflared_token"`
|
||||||
IO0Status bool `json:"io0_status"`
|
IO0Status bool `json:"io0_status"`
|
||||||
IO1Status bool `json:"io1_status"`
|
IO1Status bool `json:"io1_status"`
|
||||||
AudioMode string `json:"audio_mode"`
|
AudioMode string `json:"audio_mode"`
|
||||||
@@ -117,6 +121,19 @@ type Config struct {
|
|||||||
AutoMountSystemInfo bool `json:"auto_mount_system_info_img"`
|
AutoMountSystemInfo bool `json:"auto_mount_system_info_img"`
|
||||||
EasytierAutoStart bool `json:"easytier_autostart"`
|
EasytierAutoStart bool `json:"easytier_autostart"`
|
||||||
EasytierConfig EasytierConfig `json:"easytier_config"`
|
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"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
@@ -134,6 +151,7 @@ var defaultConfig = &Config{
|
|||||||
DisplayDimAfterSec: 120, // 2 minutes
|
DisplayDimAfterSec: 120, // 2 minutes
|
||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
TLSMode: "",
|
TLSMode: "",
|
||||||
|
ForceHpd: false, // 默认不强制输出EDID
|
||||||
UsbConfig: &usbgadget.Config{
|
UsbConfig: &usbgadget.Config{
|
||||||
VendorId: "0x1d6b", //The Linux Foundation
|
VendorId: "0x1d6b", //The Linux Foundation
|
||||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
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
|
Audio: false, //At any given time, only one of Audio and Mtp can be set to true
|
||||||
Mtp: false,
|
Mtp: false,
|
||||||
},
|
},
|
||||||
NetworkConfig: &network.NetworkConfig{},
|
NetworkConfig: &network.NetworkConfig{},
|
||||||
|
AppliedNetworkConfig: nil,
|
||||||
DefaultLogLevel: "INFO",
|
DefaultLogLevel: "INFO",
|
||||||
ZeroTierAutoStart: false,
|
ZeroTierAutoStart: false,
|
||||||
TailScaleAutoStart: false,
|
TailScaleAutoStart: false,
|
||||||
TailScaleXEdge: false,
|
TailScaleXEdge: false,
|
||||||
FrpcAutoStart: false,
|
FrpcAutoStart: false,
|
||||||
|
CloudflaredAutoStart: false,
|
||||||
IO0Status: true,
|
IO0Status: true,
|
||||||
IO1Status: true,
|
IO1Status: true,
|
||||||
AudioMode: "disabled",
|
AudioMode: "disabled",
|
||||||
|
|||||||
@@ -345,15 +345,35 @@ func (f *FieldConfig) validateField() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := toString(f.CurrentValue)
|
switch v := f.CurrentValue.(type) {
|
||||||
if err != nil {
|
case []string:
|
||||||
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
|
// 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 {
|
for _, validateType := range f.ValidateTypes {
|
||||||
switch validateType {
|
switch validateType {
|
||||||
case "ipv4":
|
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 fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ func toString(v interface{}) (string, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
case null.String:
|
case null.String:
|
||||||
return v.String, nil
|
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))
|
return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v))
|
||||||
|
|||||||
@@ -33,23 +33,24 @@ type IPv6StaticConfig struct {
|
|||||||
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
||||||
}
|
}
|
||||||
type NetworkConfig struct {
|
type NetworkConfig struct {
|
||||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||||
|
|
||||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||||
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
||||||
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
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"`
|
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"`
|
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
|
||||||
|
|
||||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
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 {
|
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"kvm/internal/confparser"
|
"kvm/internal/confparser"
|
||||||
"kvm/internal/logging"
|
"kvm/internal/logging"
|
||||||
"kvm/internal/udhcpc"
|
"kvm/internal/udhcpc"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
@@ -100,6 +103,31 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
|
|||||||
|
|
||||||
s.dhcpClient = dhcpClient
|
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
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +371,162 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
|
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
|
||||||
_ = s.setHostnameIfNotSame()
|
_ = s.setHostnameIfNotSame()
|
||||||
s.cbConfigChange(config)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"kvm/internal/confparser"
|
"kvm/internal/confparser"
|
||||||
"kvm/internal/udhcpc"
|
"kvm/internal/udhcpc"
|
||||||
|
|
||||||
|
"github.com/guregu/null/v6"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RpcIPv6Address struct {
|
type RpcIPv6Address struct {
|
||||||
@@ -99,17 +101,22 @@ func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
|
func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
|
||||||
currentSettings := s.config
|
currentSettings := s.config
|
||||||
|
|
||||||
err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
|
err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if IsSame(currentSettings, settings.NetworkConfig) {
|
neutralA := *currentSettings
|
||||||
// no changes, do nothing
|
neutralB := settings.NetworkConfig
|
||||||
return nil
|
neutralA.PendingReboot = null.Bool{}
|
||||||
}
|
neutralB.PendingReboot = null.Bool{}
|
||||||
|
|
||||||
|
if IsSame(neutralA, neutralB) {
|
||||||
|
// no changes, do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
s.config = &settings.NetworkConfig
|
s.config = &settings.NetworkConfig
|
||||||
s.onConfigChange(s.config)
|
s.onConfigChange(s.config)
|
||||||
@@ -124,3 +131,10 @@ func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
|
|||||||
|
|
||||||
return s.dhcpClient.Renew()
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type DHCPClient struct {
|
|||||||
logger *zerolog.Logger
|
logger *zerolog.Logger
|
||||||
process *os.Process
|
process *os.Process
|
||||||
onLeaseChange func(lease *Lease)
|
onLeaseChange func(lease *Lease)
|
||||||
|
enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type DHCPClientOptions struct {
|
type DHCPClientOptions struct {
|
||||||
@@ -57,6 +58,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
|
|||||||
pidFile: options.PidFile,
|
pidFile: options.PidFile,
|
||||||
onLeaseChange: options.OnLeaseChange,
|
onLeaseChange: options.OnLeaseChange,
|
||||||
requestAddress: options.RequestAddress,
|
requestAddress: options.RequestAddress,
|
||||||
|
enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +89,12 @@ func (c *DHCPClient) watchLink() {
|
|||||||
for update := range ch {
|
for update := range ch {
|
||||||
if update.Link.Attrs().Name == c.InterfaceName {
|
if update.Link.Attrs().Name == c.InterfaceName {
|
||||||
if update.Flags&unix.IFF_RUNNING != 0 {
|
if update.Flags&unix.IFF_RUNNING != 0 {
|
||||||
c.logger.Info().Msg("link is up, starting udhcpc")
|
if c.enabled {
|
||||||
go c.runUDHCPC()
|
c.logger.Info().Msg("link is up, starting udhcpc")
|
||||||
|
go c.runUDHCPC()
|
||||||
|
} else {
|
||||||
|
c.logger.Debug().Msg("link is up, DHCP disabled")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
c.logger.Info().Msg("link is down")
|
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() {
|
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")
|
cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1")
|
||||||
if c.requestAddress != "" {
|
if c.requestAddress != "" {
|
||||||
ip := net.ParseIP(c.requestAddress)
|
ip := net.ParseIP(c.requestAddress)
|
||||||
@@ -273,3 +283,29 @@ func (c *DHCPClient) loadLeaseFile() error {
|
|||||||
func (c *DHCPClient) GetLease() *Lease {
|
func (c *DHCPClient) GetLease() *Lease {
|
||||||
return c.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/procfs"
|
"github.com/prometheus/procfs"
|
||||||
@@ -406,33 +407,79 @@ func (c *ChangeSet) ApplyChanges() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ChangeSet) applyChange(change *FileChange) 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() {
|
switch change.Action() {
|
||||||
case FileChangeResolvedActionWriteFile:
|
case FileChangeResolvedActionWriteFile:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("writing file")
|
||||||
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
||||||
case FileChangeResolvedActionUpdateFile:
|
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:
|
case FileChangeResolvedActionCreateFile:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("creating file")
|
||||||
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
||||||
case FileChangeResolvedActionCreateSymlink:
|
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:
|
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 {
|
if err := os.Remove(change.Path); err != nil {
|
||||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
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:
|
case FileChangeResolvedActionReorderSymlinks:
|
||||||
|
defaultLogger.Debug().
|
||||||
|
Str("path", change.Path).
|
||||||
|
Int("symlink_count", len(change.ParamSymlinks)).
|
||||||
|
Msg("reordering symlinks")
|
||||||
return recreateSymlinks(change, nil)
|
return recreateSymlinks(change, nil)
|
||||||
case FileChangeResolvedActionCreateDirectory:
|
case FileChangeResolvedActionCreateDirectory:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("creating directory")
|
||||||
return os.MkdirAll(change.Path, 0755)
|
return os.MkdirAll(change.Path, 0755)
|
||||||
case FileChangeResolvedActionRemove:
|
case FileChangeResolvedActionRemove:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("removing file")
|
||||||
return os.Remove(change.Path)
|
return os.Remove(change.Path)
|
||||||
case FileChangeResolvedActionRemoveDirectory:
|
case FileChangeResolvedActionRemoveDirectory:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("removing directory")
|
||||||
return os.RemoveAll(change.Path)
|
return os.RemoveAll(change.Path)
|
||||||
case FileChangeResolvedActionTouch:
|
case FileChangeResolvedActionTouch:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("touching file")
|
||||||
return os.Chtimes(change.Path, time.Now(), time.Now())
|
return os.Chtimes(change.Path, time.Now(), time.Now())
|
||||||
case FileChangeResolvedActionMountConfigFS:
|
case FileChangeResolvedActionMountConfigFS:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("mounting configfs")
|
||||||
return mountConfigFS(change.Path)
|
return mountConfigFS(change.Path)
|
||||||
case FileChangeResolvedActionMountFunctionFS:
|
case FileChangeResolvedActionMountFunctionFS:
|
||||||
|
defaultLogger.Debug().Str("path", change.Path).Msg("mounting functionfs")
|
||||||
return mountFunctionFS(change.Path)
|
return mountFunctionFS(change.Path)
|
||||||
case FileChangeResolvedActionDoNothing:
|
case FileChangeResolvedActionDoNothing:
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type gadgetConfigItem struct {
|
type gadgetConfigItem struct {
|
||||||
@@ -238,19 +240,73 @@ func (u *UsbGadget) Init() error {
|
|||||||
|
|
||||||
udcs := getUdcs()
|
udcs := getUdcs()
|
||||||
if len(udcs) < 1 {
|
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)
|
return u.logWarn("no udc found, skipping USB stack init", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.udc = udcs[0]
|
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)
|
err := u.configureUsbGadget(false)
|
||||||
if err != nil {
|
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 u.logError("unable to initialize USB stack", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
func (u *UsbGadget) UpdateGadgetConfig() error {
|
||||||
u.configLock.Lock()
|
u.configLock.Lock()
|
||||||
defer u.configLock.Unlock()
|
defer u.configLock.Unlock()
|
||||||
@@ -266,13 +322,48 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) configureUsbGadget(resetUsb bool) 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 {
|
return u.WithTransaction(func() error {
|
||||||
|
u.log.Info().Msg("Transaction: Mounting configfs")
|
||||||
u.tx.MountConfigFS()
|
u.tx.MountConfigFS()
|
||||||
|
|
||||||
|
u.log.Info().Msg("Transaction: Creating config path")
|
||||||
u.tx.CreateConfigPath()
|
u.tx.CreateConfigPath()
|
||||||
|
|
||||||
|
u.log.Info().Msg("Transaction: Writing gadget configuration")
|
||||||
u.tx.WriteGadgetConfig()
|
u.tx.WriteGadgetConfig()
|
||||||
|
|
||||||
if resetUsb {
|
if resetUsb {
|
||||||
|
u.log.Info().Msg("Transaction: Rebinding USB")
|
||||||
u.tx.RebindUsb(true)
|
u.tx.RebindUsb(true)
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
@@ -151,7 +152,10 @@ func (tx *UsbGadgetTransaction) CreateConfigPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
|
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
|
||||||
|
tx.log.Info().Msg("=== Building USB gadget configuration ===")
|
||||||
|
|
||||||
// create kvm gadget path
|
// create kvm gadget path
|
||||||
|
tx.log.Info().Str("path", tx.kvmGadgetPath).Msg("creating kvm gadget path")
|
||||||
tx.mkdirAll(
|
tx.mkdirAll(
|
||||||
"gadget",
|
"gadget",
|
||||||
tx.kvmGadgetPath,
|
tx.kvmGadgetPath,
|
||||||
@@ -162,22 +166,43 @@ func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
|
|||||||
deps := make([]string, 0)
|
deps := make([]string, 0)
|
||||||
deps = append(deps, tx.kvmGadgetPath)
|
deps = append(deps, tx.kvmGadgetPath)
|
||||||
|
|
||||||
|
enabledCount := 0
|
||||||
|
disabledCount := 0
|
||||||
for _, val := range tx.orderedConfigItems {
|
for _, val := range tx.orderedConfigItems {
|
||||||
key := val.key
|
key := val.key
|
||||||
item := val.item
|
item := val.item
|
||||||
|
|
||||||
// check if the item is enabled in the config
|
// check if the item is enabled in the config
|
||||||
if !tx.isGadgetConfigItemEnabled(key) {
|
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)
|
tx.DisableGadgetItemConfig(item)
|
||||||
|
disabledCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx.log.Info().
|
||||||
|
Str("key", key).
|
||||||
|
Str("device", item.device).
|
||||||
|
Uint("order", item.order).
|
||||||
|
Msg("configuring gadget item")
|
||||||
deps = tx.writeGadgetItemConfig(item, deps)
|
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") {
|
if tx.isGadgetConfigItemEnabled("mtp") {
|
||||||
|
tx.log.Info().Msg("MTP enabled, mounting functionfs and binding UDC")
|
||||||
tx.MountFunctionFS()
|
tx.MountFunctionFS()
|
||||||
tx.WriteUDC(true)
|
tx.WriteUDC(true)
|
||||||
} else {
|
} else {
|
||||||
|
tx.log.Info().Msg("MTP disabled, binding UDC directly")
|
||||||
tx.WriteUDC(false)
|
tx.WriteUDC(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +251,22 @@ func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, dep
|
|||||||
beforeChange = append(beforeChange, tx.getDisableKeys()...)
|
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 {
|
if len(item.attrs) > 0 {
|
||||||
// write attributes for the item
|
// write attributes for the item
|
||||||
files = append(files, tx.writeGadgetAttrs(
|
files = append(files, tx.writeGadgetAttrs(
|
||||||
@@ -355,9 +396,12 @@ func (tx *UsbGadgetTransaction) WriteUDC(mtpServer bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError 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
|
// remove the gadget from the UDC
|
||||||
tx.addFileChange("udc", RequestedFileChange{
|
tx.addFileChange("udc", RequestedFileChange{
|
||||||
Path: path.Join(tx.dwc3Path, "unbind"),
|
Path: unbindPath,
|
||||||
ExpectedState: FileStateFileWrite,
|
ExpectedState: FileStateFileWrite,
|
||||||
ExpectedContent: []byte(tx.udc),
|
ExpectedContent: []byte(tx.udc),
|
||||||
Description: "unbind UDC",
|
Description: "unbind UDC",
|
||||||
@@ -366,10 +410,10 @@ func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
|
|||||||
})
|
})
|
||||||
// bind the gadget to the UDC
|
// bind the gadget to the UDC
|
||||||
tx.addFileChange("udc", RequestedFileChange{
|
tx.addFileChange("udc", RequestedFileChange{
|
||||||
Path: path.Join(tx.dwc3Path, "bind"),
|
Path: bindPath,
|
||||||
ExpectedState: FileStateFileWrite,
|
ExpectedState: FileStateFileWrite,
|
||||||
ExpectedContent: []byte(tx.udc),
|
ExpectedContent: []byte(tx.udc),
|
||||||
Description: "bind UDC",
|
Description: "bind UDC",
|
||||||
DependsOn: []string{path.Join(tx.dwc3Path, "unbind")},
|
DependsOn: []string{unbindPath},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package usbgadget
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,6 +120,10 @@ func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
|
|||||||
u.onKeyboardStateChange = &f
|
u.onKeyboardStateChange = &f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) SetOnHidDeviceMissing(f func(device string, err error)) {
|
||||||
|
u.onHidDeviceMissing = &f
|
||||||
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
||||||
u.keyboardStateLock.Lock()
|
u.keyboardStateLock.Lock()
|
||||||
defer u.keyboardStateLock.Unlock()
|
defer u.keyboardStateLock.Unlock()
|
||||||
@@ -177,6 +183,16 @@ func (u *UsbGadget) openKeyboardHidFile() error {
|
|||||||
var err error
|
var err error
|
||||||
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("failed to open hidg0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var absoluteMouseConfig = gadgetConfigItem{
|
var absoluteMouseConfig = gadgetConfigItem{
|
||||||
@@ -69,6 +71,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
|||||||
var err error
|
var err error
|
||||||
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
|
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var relativeMouseConfig = gadgetConfigItem{
|
var relativeMouseConfig = gadgetConfigItem{
|
||||||
@@ -59,7 +61,19 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
|||||||
var err error
|
var err error
|
||||||
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
|
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ type UsbGadget struct {
|
|||||||
txLock sync.Mutex
|
txLock sync.Mutex
|
||||||
|
|
||||||
onKeyboardStateChange *func(state KeyboardState)
|
onKeyboardStateChange *func(state KeyboardState)
|
||||||
|
onHidDeviceMissing *func(device string, err error)
|
||||||
|
|
||||||
log *zerolog.Logger
|
log *zerolog.Logger
|
||||||
|
|
||||||
@@ -140,8 +141,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
|
|||||||
absMouseAccumulatedWheelY: 0,
|
absMouseAccumulatedWheelY: 0,
|
||||||
}
|
}
|
||||||
if err := g.Init(); err != nil {
|
if err := g.Init(); err != nil {
|
||||||
logger.Error().Err(err).Msg("failed to init USB gadget")
|
logger.Error().Err(err).Msg("failed to init USB gadget (will retry later)")
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return g
|
return g
|
||||||
|
|||||||
62
jsonrpc.go
62
jsonrpc.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
@@ -250,6 +251,51 @@ func rpcSetEDID(edid string) error {
|
|||||||
return nil
|
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) {
|
func rpcGetDevChannelState() (bool, error) {
|
||||||
return config.IncludePreRelease, nil
|
return config.IncludePreRelease, nil
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1096,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||||
|
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
|
||||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||||
@@ -1057,6 +1104,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||||
"getVideoState": {Func: rpcGetVideoState},
|
"getVideoState": {Func: rpcGetVideoState},
|
||||||
"getUSBState": {Func: rpcGetUSBState},
|
"getUSBState": {Func: rpcGetUSBState},
|
||||||
|
"reinitializeUsbGadget": {Func: rpcReinitializeUsbGadget},
|
||||||
|
"reinitializeUsbGadgetSoft": {Func: rpcReinitializeUsbGadgetSoft},
|
||||||
"unmountImage": {Func: rpcUnmountImage},
|
"unmountImage": {Func: rpcUnmountImage},
|
||||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||||
@@ -1069,6 +1118,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||||
"getEDID": {Func: rpcGetEDID},
|
"getEDID": {Func: rpcGetEDID},
|
||||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||||
|
"setForceHpd": {Func: rpcSetForceHpd, Params: []string{"forceHpd"}},
|
||||||
|
"getForceHpd": {Func: rpcGetForceHpd},
|
||||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||||
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
||||||
@@ -1154,5 +1205,16 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getEasyTierStatus": {Func: rpcGetEasyTierStatus},
|
"getEasyTierStatus": {Func: rpcGetEasyTierStatus},
|
||||||
"getEasyTierConfig": {Func: rpcGetEasyTierConfig},
|
"getEasyTierConfig": {Func: rpcGetEasyTierConfig},
|
||||||
"getEasyTierLog": {Func: rpcGetEasyTierLog},
|
"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},
|
"getEasyTierNodeInfo": {Func: rpcGetEasyTierNodeInfo},
|
||||||
|
"startCloudflared": {Func: rpcStartCloudflared, Params: []string{"token"}},
|
||||||
|
"stopCloudflared": {Func: rpcStopCloudflared},
|
||||||
|
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
||||||
|
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
||||||
}
|
}
|
||||||
|
|||||||
3
main.go
3
main.go
@@ -31,8 +31,9 @@ func Main() {
|
|||||||
Interface("app_version", appVersionLocal).
|
Interface("app_version", appVersionLocal).
|
||||||
Msg("starting KVM")
|
Msg("starting KVM")
|
||||||
|
|
||||||
go runWatchdog()
|
//go runWatchdog()
|
||||||
go confirmCurrentSystem() //A/B system
|
go confirmCurrentSystem() //A/B system
|
||||||
|
go setForceHpd()
|
||||||
|
|
||||||
http.DefaultClient.Timeout = 1 * time.Minute
|
http.DefaultClient.Timeout = 1 * time.Minute
|
||||||
|
|
||||||
|
|||||||
41
network.go
41
network.go
@@ -56,6 +56,7 @@ func initNetwork() error {
|
|||||||
},
|
},
|
||||||
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
||||||
config.NetworkConfig = networkConfig
|
config.NetworkConfig = networkConfig
|
||||||
|
config.AppliedNetworkConfig = networkConfig
|
||||||
networkStateChanged()
|
networkStateChanged()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -73,6 +74,22 @@ func initNetwork() error {
|
|||||||
|
|
||||||
networkState = state
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +105,29 @@ func rpcGetNetworkSettings() network.RpcNetworkSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
|
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)
|
s := networkState.RpcSetNetworkSettings(settings)
|
||||||
if s != nil {
|
if s != nil {
|
||||||
return nil, s
|
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 {
|
if err := SaveConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -103,3 +138,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
|
|||||||
func rpcRenewDHCPLease() error {
|
func rpcRenewDHCPLease() error {
|
||||||
return networkState.RpcRenewDHCPLease()
|
return networkState.RpcRenewDHCPLease()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcRequestDHCPAddress(ip string) error {
|
||||||
|
return networkState.RpcRequestDHCPAddress(ip)
|
||||||
|
}
|
||||||
|
|||||||
2
ota.go
2
ota.go
@@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{
|
|||||||
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
|
"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"
|
var updateSource = "github"
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function DashboardNavbar({
|
|||||||
<div className="inline-block shrink-0">
|
<div className="inline-block shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="https://wiki.luckfox.com/Luckfox-Pico/Download"
|
href="https://wiki.luckfox.com/intro/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-4"
|
className="flex items-center gap-4"
|
||||||
|
|||||||
46
ui/src/components/Tabs.tsx
Normal file
46
ui/src/components/Tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -263,6 +263,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
<li>
|
<li>
|
||||||
{$at("If using an adapter, ensure it's compatible and functioning correctly")}
|
{$at("If using an adapter, ensure it's compatible and functioning correctly")}
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
{$at("Certain motherboards do not support simultaneous multi-display output")}
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{$at("Ensure source device is not in sleep mode and outputting a signal")}
|
{$at("Ensure source device is not in sleep mode and outputting a signal")}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -44,6 +44,30 @@ function KeyboardWrapper() {
|
|||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [newPosition, setNewPosition] = 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));
|
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
|
||||||
|
|
||||||
// HID related states
|
// HID related states
|
||||||
@@ -132,11 +156,62 @@ function KeyboardWrapper() {
|
|||||||
const cleanKey = key.replace(/[()]/g, "");
|
const cleanKey = key.replace(/[()]/g, "");
|
||||||
const keyHasShiftModifier = key.includes("(");
|
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
|
// Handle toggle of layout for shift or caps lock
|
||||||
const toggleLayout = () => {
|
const toggleLayout = () => {
|
||||||
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
|
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") {
|
if (key === "CtrlAltDelete") {
|
||||||
sendKeyboardEvent(
|
sendKeyboardEvent(
|
||||||
[keys["Delete"]],
|
[keys["Delete"]],
|
||||||
@@ -166,7 +241,7 @@ function KeyboardWrapper() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKeyShift || isKeyCaps) {
|
if (isKeyCaps) {
|
||||||
toggleLayout();
|
toggleLayout();
|
||||||
|
|
||||||
if (isCapsLockActive) {
|
if (isCapsLockActive) {
|
||||||
@@ -185,25 +260,61 @@ function KeyboardWrapper() {
|
|||||||
|
|
||||||
// Collect new active keys and modifiers
|
// Collect new active keys and modifiers
|
||||||
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
|
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
|
||||||
const newModifiers =
|
const newModifiers: number[] = [];
|
||||||
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
|
|
||||||
|
// 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
|
// Update current keys and modifiers
|
||||||
sendKeyboardEvent(newKeys, newModifiers);
|
sendKeyboardEvent(newKeys, newModifiers);
|
||||||
|
|
||||||
// If shift was used as a modifier and caps lock is not active, revert to default layout
|
// 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) {
|
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");
|
setLayoutName("default");
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(resetKeyboardState, 100);
|
setTimeout(resetKeyboardState, 100);
|
||||||
},
|
},
|
||||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
|
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive, lockedModifiers, modifierLockMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="transition-all duration-500 ease-in-out"
|
className="transition-all duration-500 ease-in-out"
|
||||||
@@ -259,9 +370,11 @@ function KeyboardWrapper() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
<div className="flex flex-col items-center gap-y-1">
|
||||||
{$at("Virtual Keyboard")}
|
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||||
</h2>
|
{$at("Virtual Keyboard")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="absolute right-2">
|
<div className="absolute right-2">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
@@ -274,21 +387,64 @@ function KeyboardWrapper() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
|
||||||
<Keyboard
|
<Keyboard
|
||||||
baseClass="simple-keyboard-main"
|
baseClass="simple-keyboard-main"
|
||||||
layoutName={layoutName}
|
layoutName={layoutName}
|
||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
buttonTheme={[
|
buttonTheme={
|
||||||
{
|
modifierLockMode && modifierLockButtons
|
||||||
class: "combination-key",
|
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
||||||
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
: []
|
||||||
},
|
}
|
||||||
]}
|
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={{
|
||||||
default: [
|
default: [
|
||||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
"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",
|
"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",
|
"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",
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||||
],
|
],
|
||||||
shift: [
|
shift: [
|
||||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
"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)",
|
"(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)",
|
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import useKeyboard from "@/hooks/useKeyboard";
|
|||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import { chars } from "@/keyboardLayouts";
|
||||||
import {
|
import {
|
||||||
useHidStore,
|
useHidStore,
|
||||||
useMouseStore,
|
useMouseStore,
|
||||||
@@ -30,15 +31,25 @@ export default function WebRTCVideo() {
|
|||||||
// Video and stream related refs and states
|
// Video and stream related refs and states
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
const audioElm = useRef<HTMLAudioElement>(null);
|
const audioElm = useRef<HTMLAudioElement>(null);
|
||||||
|
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
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
|
// Store hooks
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
||||||
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
||||||
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
const {
|
const {
|
||||||
setClientSize: setVideoClientSize,
|
setClientSize: setVideoClientSize,
|
||||||
setSize: setVideoSize,
|
setSize: setVideoSize,
|
||||||
@@ -78,6 +89,66 @@ export default function WebRTCVideo() {
|
|||||||
// Misc states and hooks
|
// Misc states and hooks
|
||||||
const [send] = useJsonRpc();
|
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
|
// Video-related
|
||||||
useResizeObserver({
|
useResizeObserver({
|
||||||
ref: videoElm as React.RefObject<HTMLElement>,
|
ref: videoElm as React.RefObject<HTMLElement>,
|
||||||
@@ -223,7 +294,7 @@ export default function WebRTCVideo() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("fullscreenchange ", handleFullscreenChange);
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
}, [releaseKeyboardLock]);
|
}, [releaseKeyboardLock]);
|
||||||
|
|
||||||
// Mouse-related
|
// Mouse-related
|
||||||
@@ -232,16 +303,24 @@ export default function WebRTCVideo() {
|
|||||||
const sendRelMouseMovement = useCallback(
|
const sendRelMouseMovement = useCallback(
|
||||||
(x: number, y: number, buttons: number) => {
|
(x: number, y: number, buttons: number) => {
|
||||||
if (settings.mouseMode !== "relative") return;
|
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 we ignore the event, double-click will not work
|
||||||
// if (x === 0 && y === 0 && buttons === 0) return;
|
// if (x === 0 && y === 0 && buttons === 0) return;
|
||||||
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
||||||
setMouseMove({ x, y, buttons });
|
setMouseMove({ x, y, buttons });
|
||||||
},
|
},
|
||||||
[send, setMouseMove, settings.mouseMode],
|
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
||||||
);
|
);
|
||||||
|
|
||||||
const relMouseMoveHandler = useCallback(
|
const relMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(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 (settings.mouseMode !== "relative") return;
|
||||||
if (isPointerLockActive === false && isPointerLockPossible) return;
|
if (isPointerLockActive === false && isPointerLockPossible) return;
|
||||||
|
|
||||||
@@ -255,15 +334,25 @@ export default function WebRTCVideo() {
|
|||||||
const sendAbsMouseMovement = useCallback(
|
const sendAbsMouseMovement = useCallback(
|
||||||
(x: number, y: number, buttons: number) => {
|
(x: number, y: number, buttons: number) => {
|
||||||
if (settings.mouseMode !== "absolute") return;
|
if (settings.mouseMode !== "absolute") return;
|
||||||
|
// Don't send mouse events while reinitializing gadget
|
||||||
|
if (isReinitializingGadget) return;
|
||||||
send("absMouseReport", { x, y, buttons });
|
send("absMouseReport", { x, y, buttons });
|
||||||
// We set that for the debug info bar
|
// We set that for the debug info bar
|
||||||
setMousePosition(x, y);
|
setMousePosition(x, y);
|
||||||
},
|
},
|
||||||
[send, setMousePosition, settings.mouseMode],
|
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
||||||
);
|
);
|
||||||
|
|
||||||
const absMouseMoveHandler = useCallback(
|
const absMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(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 (!videoClientWidth || !videoClientHeight) return;
|
||||||
if (settings.mouseMode !== "absolute") return;
|
if (settings.mouseMode !== "absolute") return;
|
||||||
|
|
||||||
@@ -287,9 +376,13 @@ export default function WebRTCVideo() {
|
|||||||
offsetY = (videoClientHeight - effectiveHeight) / 2;
|
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
|
// Clamp mouse position within the effective video boundaries
|
||||||
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
|
const clampedX = Math.min(Math.max(offsetX, inputOffsetX), offsetX + effectiveWidth);
|
||||||
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
|
const clampedY = Math.min(Math.max(offsetY, inputOffsetY), offsetY + effectiveHeight);
|
||||||
|
|
||||||
// Map clamped mouse position to the video stream's coordinate system
|
// Map clamped mouse position to the video stream's coordinate system
|
||||||
const relativeX = (clampedX - offsetX) / effectiveWidth;
|
const relativeX = (clampedX - offsetX) / effectiveWidth;
|
||||||
@@ -303,11 +396,13 @@ export default function WebRTCVideo() {
|
|||||||
const { buttons } = e;
|
const { buttons } = e;
|
||||||
sendAbsMouseMovement(x, y, buttons);
|
sendAbsMouseMovement(x, y, buttons);
|
||||||
},
|
},
|
||||||
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement],
|
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement, mobileScale, mobileTx, mobileTy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mouseWheelHandler = useCallback(
|
const mouseWheelHandler = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
|
// Don't send wheel events while reinitializing gadget
|
||||||
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
if (settings.scrollThrottling && blockWheelEvent) {
|
if (settings.scrollThrottling && blockWheelEvent) {
|
||||||
return;
|
return;
|
||||||
@@ -339,7 +434,7 @@ export default function WebRTCVideo() {
|
|||||||
setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
|
setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[send, blockWheelEvent, settings],
|
[send, blockWheelEvent, settings, isReinitializingGadget],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetMousePosition = useCallback(() => {
|
const resetMousePosition = useCallback(() => {
|
||||||
@@ -414,6 +509,16 @@ export default function WebRTCVideo() {
|
|||||||
|
|
||||||
const keyDownHandler = useCallback(
|
const keyDownHandler = useCallback(
|
||||||
async (e: KeyboardEvent) => {
|
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();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const prev = useHidStore.getState();
|
||||||
let code = e.code;
|
let code = e.code;
|
||||||
@@ -456,10 +561,15 @@ export default function WebRTCVideo() {
|
|||||||
[
|
[
|
||||||
handleModifierKeys,
|
handleModifierKeys,
|
||||||
sendKeyboardEvent,
|
sendKeyboardEvent,
|
||||||
|
send,
|
||||||
isKeyboardLedManagedByHost,
|
isKeyboardLedManagedByHost,
|
||||||
setIsNumLockActive,
|
setIsNumLockActive,
|
||||||
setIsCapsLockActive,
|
setIsCapsLockActive,
|
||||||
setIsScrollLockActive,
|
setIsScrollLockActive,
|
||||||
|
overrideCtrlV,
|
||||||
|
pasteCaptureRef,
|
||||||
|
safeKeyboardLayout,
|
||||||
|
isReinitializingGadget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -568,23 +678,20 @@ export default function WebRTCVideo() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Setup Keyboard Events
|
// Setup Keyboard Events
|
||||||
useEffect(
|
useEffect(function setupKeyboardEvents() {
|
||||||
function setupKeyboardEvents() {
|
const abortController = new AbortController();
|
||||||
const abortController = new AbortController();
|
const signal = abortController.signal;
|
||||||
const signal = abortController.signal;
|
|
||||||
|
|
||||||
document.addEventListener("keydown", keyDownHandler, { signal });
|
document.addEventListener("keydown", keyDownHandler, { signal });
|
||||||
document.addEventListener("keyup", keyUpHandler, { signal });
|
document.addEventListener("keyup", keyUpHandler, { signal });
|
||||||
|
|
||||||
window.addEventListener("blur", resetKeyboardState, { signal });
|
window.addEventListener("blur", resetKeyboardState, { signal });
|
||||||
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
|
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
},
|
}, [keyDownHandler, keyUpHandler, resetKeyboardState]);
|
||||||
[keyDownHandler, keyUpHandler, resetKeyboardState],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Setup Video Event Listeners
|
// Setup Video Event Listeners
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -608,6 +715,14 @@ export default function WebRTCVideo() {
|
|||||||
[onVideoPlaying, videoKeyUpHandler],
|
[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
|
// Setup Mouse Events
|
||||||
useEffect(
|
useEffect(
|
||||||
function setMouseModeEventListeners() {
|
function setMouseModeEventListeners() {
|
||||||
@@ -652,6 +767,90 @@ export default function WebRTCVideo() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||||
if (peerConnection?.connectionState !== "connected") return false;
|
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 */}
|
{/* 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} />
|
<PointerLockBar show={showPointerLockBar} />
|
||||||
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
<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
|
<video
|
||||||
ref={videoElm}
|
ref={videoElm}
|
||||||
autoPlay={true}
|
autoPlay={true}
|
||||||
@@ -767,6 +974,37 @@ export default function WebRTCVideo() {
|
|||||||
<div>
|
<div>
|
||||||
<InfoBar />
|
<InfoBar />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
173
ui/src/components/extensions/PowerControl.tsx
Normal file
173
ui/src/components/extensions/PowerControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { keys, modifiers } from "@/keyboardMappings";
|
|||||||
import { layouts, chars } from "@/keyboardLayouts";
|
import { layouts, chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import {useReactAt} from 'i18n-auto-extractor/react'
|
import {useReactAt} from 'i18n-auto-extractor/react'
|
||||||
|
import { Checkbox } from "@/components/Checkbox";
|
||||||
|
|
||||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||||
return { keys, modifier };
|
return { keys, modifier };
|
||||||
@@ -28,11 +29,15 @@ export default function PasteModal() {
|
|||||||
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
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 close = useClose();
|
||||||
|
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||||
@@ -59,54 +64,126 @@ export default function PasteModal() {
|
|||||||
setPasteMode(false);
|
setPasteMode(false);
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
setInvalidChars([]);
|
setInvalidChars([]);
|
||||||
|
// keep override state persistent via settings store; do not reset here
|
||||||
}, [setDisableVideoFocusTrap, setPasteMode]);
|
}, [setDisableVideoFocusTrap, setPasteMode]);
|
||||||
|
|
||||||
const onConfirmPaste = useCallback(async () => {
|
const onConfirmPaste = useCallback(async () => {
|
||||||
setPasteMode(false);
|
setPasteMode(false);
|
||||||
setDisableVideoFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
||||||
|
// Don't send keyboard events while reinitializing gadget
|
||||||
|
if (isReinitializingGadget) {
|
||||||
|
notifications.error("USB gadget is reinitializing, please wait...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!safeKeyboardLayout) return;
|
if (!safeKeyboardLayout) return;
|
||||||
if (!chars[safeKeyboardLayout]) return;
|
if (!chars[safeKeyboardLayout]) return;
|
||||||
const text = TextAreaRef.current.value;
|
const text = TextAreaRef.current.value;
|
||||||
|
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;
|
||||||
|
|
||||||
try {
|
const keyz = [keys[key]];
|
||||||
for (const char of text) {
|
const modz = [modifierCode(shift, altRight)];
|
||||||
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
|
|
||||||
if (!key) continue;
|
|
||||||
|
|
||||||
const keyz = [ keys[key] ];
|
if (deadKey) {
|
||||||
const modz = [ modifierCode(shift, altRight) ];
|
|
||||||
|
|
||||||
if (deadKey) {
|
|
||||||
keyz.push(keys["Space"]);
|
keyz.push(keys["Space"]);
|
||||||
modz.push(noModifier);
|
modz.push(noModifier);
|
||||||
}
|
}
|
||||||
if (accentKey) {
|
if (accentKey) {
|
||||||
keyz.unshift(keys[accentKey.key])
|
keyz.unshift(keys[accentKey.key]);
|
||||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
|
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, kei] of keyz.entries()) {
|
for (const [index, kei] of keyz.entries()) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
send(
|
send(
|
||||||
"keyboardReport",
|
"keyboardReport",
|
||||||
hidKeyboardPayload([kei], modz[index]),
|
hidKeyboardPayload([kei], modz[index]),
|
||||||
params => {
|
params => {
|
||||||
if ("error" in params) return reject(params.error);
|
|
||||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
|
||||||
if ("error" in params) return reject(params.error);
|
if ("error" in params) return reject(params.error);
|
||||||
resolve();
|
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");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
};
|
||||||
console.error(error);
|
|
||||||
notifications.error("Failed to paste text");
|
await sendText(text);
|
||||||
}
|
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout, isReinitializingGadget]);
|
||||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (TextAreaRef.current) {
|
if (TextAreaRef.current) {
|
||||||
@@ -125,6 +202,18 @@ export default function PasteModal() {
|
|||||||
description={$at("Paste text from your client to the remote host")}
|
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
|
<div
|
||||||
className="animate-fadeIn opacity-0 space-y-2"
|
className="animate-fadeIn opacity-0 space-y-2"
|
||||||
style={{
|
style={{
|
||||||
@@ -133,44 +222,122 @@ export default function PasteModal() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
<div
|
||||||
<TextAreaWithLabel
|
className="w-full"
|
||||||
ref={TextAreaRef}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
label={$at("Paste from host")}
|
onKeyDown={e => {
|
||||||
rows={4}
|
e.stopPropagation();
|
||||||
onKeyUp={e => e.stopPropagation()}
|
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
||||||
onKeyDown={e => {
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
readClipboardToBufferAndSend();
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
}
|
||||||
e.preventDefault();
|
}}
|
||||||
onConfirmPaste();
|
onPaste={e => {
|
||||||
} else if (e.key === "Escape") {
|
if (overrideCtrlV) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCancelPasteMode();
|
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();
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
onChange={e => {
|
}}
|
||||||
const value = e.target.value;
|
>
|
||||||
const invalidChars = [
|
{!overrideCtrlV && (
|
||||||
...new Set(
|
<>
|
||||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
<TextAreaWithLabel
|
||||||
[...new Intl.Segmenter().segment(value)]
|
ref={TextAreaRef}
|
||||||
.map(x => x.segment)
|
label={$at("Paste from host")}
|
||||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
rows={4}
|
||||||
),
|
onKeyUp={e => e.stopPropagation()}
|
||||||
];
|
onKeyDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirmPaste();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancelPasteMode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const invalidChars = [
|
||||||
|
...new Set(
|
||||||
|
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||||
|
[...new Intl.Segmenter().segment(value)]
|
||||||
|
.map(x => x.segment)
|
||||||
|
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
setInvalidChars(invalidChars);
|
setInvalidChars(invalidChars);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{invalidChars.length > 0 && (
|
{invalidChars.length > 0 && (
|
||||||
<div className="mt-2 flex items-center gap-x-2">
|
<div className="mt-2 flex items-center gap-x-2">
|
||||||
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
||||||
<span className="text-xs text-red-500 dark:text-red-400">
|
<span className="text-xs text-red-500 dark:text-red-400">
|
||||||
{$at("The following characters will not be pasted:")} {invalidChars.join(", ")}
|
{$at("The following characters will not be pasted:")} {invalidChars.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,13 +366,15 @@ export default function PasteModal() {
|
|||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
{!overrideCtrlV && (
|
||||||
size="SM"
|
<Button
|
||||||
theme="primary"
|
size="SM"
|
||||||
text={$at("Confirm paste")}
|
theme="primary"
|
||||||
onClick={onConfirmPaste}
|
text={$at("Confirm paste")}
|
||||||
LeadingIcon={LuCornerDownLeft}
|
onClick={onConfirmPaste}
|
||||||
/>
|
LeadingIcon={LuCornerDownLeft}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</GridCard>
|
||||||
|
|||||||
@@ -346,6 +346,9 @@ interface SettingsState {
|
|||||||
showPressedKeys: boolean;
|
showPressedKeys: boolean;
|
||||||
setShowPressedKeys: (show: boolean) => void;
|
setShowPressedKeys: (show: boolean) => void;
|
||||||
|
|
||||||
|
overrideCtrlV: boolean;
|
||||||
|
setOverrideCtrlV: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Video enhancement settings
|
// Video enhancement settings
|
||||||
videoSaturation: number;
|
videoSaturation: number;
|
||||||
setVideoSaturation: (value: number) => void;
|
setVideoSaturation: (value: number) => void;
|
||||||
@@ -409,6 +412,9 @@ export const useSettingsStore = create(
|
|||||||
showPressedKeys: true,
|
showPressedKeys: true,
|
||||||
setShowPressedKeys: show => set({ showPressedKeys: show }),
|
setShowPressedKeys: show => set({ showPressedKeys: show }),
|
||||||
|
|
||||||
|
overrideCtrlV: false,
|
||||||
|
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||||
|
|
||||||
// Video enhancement settings with default values (1.0 = normal)
|
// Video enhancement settings with default values (1.0 = normal)
|
||||||
videoSaturation: 1.0,
|
videoSaturation: 1.0,
|
||||||
setVideoSaturation: value => set({ videoSaturation: value }),
|
setVideoSaturation: value => set({ videoSaturation: value }),
|
||||||
@@ -524,6 +530,9 @@ export interface HidState {
|
|||||||
|
|
||||||
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed" | "default";
|
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed" | "default";
|
||||||
setUsbState: (state: HidState["usbState"]) => void;
|
setUsbState: (state: HidState["usbState"]) => void;
|
||||||
|
|
||||||
|
isReinitializingGadget: boolean;
|
||||||
|
setIsReinitializingGadget: (reinitializing: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useHidStore = create<HidState>((set, get) => ({
|
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
|
// Add these new properties for USB state
|
||||||
usbState: "not attached",
|
usbState: "not attached",
|
||||||
setUsbState: state => set({ usbState: state }),
|
setUsbState: state => set({ usbState: state }),
|
||||||
|
|
||||||
|
isReinitializingGadget: false,
|
||||||
|
setIsReinitializingGadget: reinitializing => set({ isReinitializingGadget: reinitializing }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
@@ -808,15 +820,25 @@ export type TimeSyncMode =
|
|||||||
| "custom"
|
| "custom"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
|
export interface IPv4StaticConfig {
|
||||||
|
address?: string;
|
||||||
|
netmask?: string;
|
||||||
|
gateway?: string;
|
||||||
|
dns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
ipv4_mode: IPv4Mode;
|
ipv4_mode: IPv4Mode;
|
||||||
|
ipv4_request_address?: string;
|
||||||
|
ipv4_static?: IPv4StaticConfig;
|
||||||
ipv6_mode: IPv6Mode;
|
ipv6_mode: IPv6Mode;
|
||||||
lldp_mode: LLDPMode;
|
lldp_mode: LLDPMode;
|
||||||
lldp_tx_tlvs: string[];
|
lldp_tx_tlvs: string[];
|
||||||
mdns_mode: mDNSMode;
|
mdns_mode: mDNSMode;
|
||||||
time_sync_mode: TimeSyncMode;
|
time_sync_mode: TimeSyncMode;
|
||||||
|
pending_reboot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
|||||||
// The "API" can also "request" data from the client
|
// The "API" can also "request" data from the client
|
||||||
// If the payload has a method, it's a request
|
// If the payload has a method, it's a request
|
||||||
if ("method" in payload) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
@@ -11,18 +12,31 @@ export default function useKeyboard() {
|
|||||||
const updateActiveKeysAndModifiers = useHidStore(
|
const updateActiveKeysAndModifiers = useHidStore(
|
||||||
state => state.updateActiveKeysAndModifiers,
|
state => state.updateActiveKeysAndModifiers,
|
||||||
);
|
);
|
||||||
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const usbState = useHidStore(state => state.usbState);
|
||||||
|
|
||||||
const sendKeyboardEvent = useCallback(
|
const sendKeyboardEvent = useCallback(
|
||||||
(keys: number[], modifiers: number[]) => {
|
(keys: number[], modifiers: number[]) => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
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);
|
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
|
// We do this for the info bar to display the currently pressed keys for the user
|
||||||
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
|
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
|
||||||
},
|
},
|
||||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
|
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetKeyboardState = useCallback(() => {
|
const resetKeyboardState = useCallback(() => {
|
||||||
|
|||||||
@@ -315,6 +315,16 @@ video::-webkit-media-controls {
|
|||||||
@apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
|
@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-container,
|
||||||
.hg-theme-default .hg-row .hg-button:not(:last-child) {
|
.hg-theme-default .hg-row .hg-button:not(:last-child) {
|
||||||
@apply mr-[2px]! md:mr-[5px]!;
|
@apply mr-[2px]! md:mr-[5px]!;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"1281764038": "Unmount",
|
"1281764038": "Unmount",
|
||||||
"2188692750": "Send a Signal to wake up the device connected via USB.",
|
"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",
|
"3899981764": "You can only add a maximum of",
|
||||||
"514d8a494f": "Terminal",
|
"514d8a494f": "Terminal",
|
||||||
"ef4f580086": "Paste Text",
|
"ef4f580086": "Paste Text",
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
"a90c4aa86f": "Ensure the HDMI cable securely connected at both ends",
|
"a90c4aa86f": "Ensure the HDMI cable securely connected at both ends",
|
||||||
"6fd1357371": "Ensure source device is powered on and outputting a signal",
|
"6fd1357371": "Ensure source device is powered on and outputting a signal",
|
||||||
"3ea74f7e5e": "If using an adapter, ensure it's compatible and functioning correctly",
|
"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",
|
"54b1e31a57": "Ensure source device is not in sleep mode and outputting a signal",
|
||||||
"693a670a35": "Try Wakeup",
|
"693a670a35": "Try Wakeup",
|
||||||
"d59048f21f": "Learn more",
|
"d59048f21f": "Learn more",
|
||||||
@@ -87,6 +90,9 @@
|
|||||||
"8fc3022cab": "Click on the video to enable mouse control",
|
"8fc3022cab": "Click on the video to enable mouse control",
|
||||||
"f200763a0d": "Detach",
|
"f200763a0d": "Detach",
|
||||||
"7193518e6f": "Attach",
|
"7193518e6f": "Attach",
|
||||||
|
"1c6a47f19f": "Lock Mode",
|
||||||
|
"b2759484fd": "Click to switch to direct trigger mode",
|
||||||
|
"80461c2be2": "Click to switch to lock mode",
|
||||||
"703703879d": "IO Control",
|
"703703879d": "IO Control",
|
||||||
"4ae517d1d2": "Configure your io control settings",
|
"4ae517d1d2": "Configure your io control settings",
|
||||||
"258f49887e": "Up",
|
"258f49887e": "Up",
|
||||||
@@ -113,6 +119,7 @@
|
|||||||
"ae94be3cd5": "Manager",
|
"ae94be3cd5": "Manager",
|
||||||
"5fe0273ec6": "Paste text",
|
"5fe0273ec6": "Paste text",
|
||||||
"2a5a0639a5": "Paste text from your client to the remote host",
|
"2a5a0639a5": "Paste text from your client to the remote host",
|
||||||
|
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
|
||||||
"6d161c8084": "Paste from host",
|
"6d161c8084": "Paste from host",
|
||||||
"604c45fbf2": "The following characters will not be pasted:",
|
"604c45fbf2": "The following characters will not be pasted:",
|
||||||
"a7eb9efa0b": "Sending text using keyboard layout:",
|
"a7eb9efa0b": "Sending text using keyboard layout:",
|
||||||
@@ -217,33 +224,53 @@
|
|||||||
"3d0de21428": "Update your device access password",
|
"3d0de21428": "Update your device access password",
|
||||||
"f8508f576c": "Remote",
|
"f8508f576c": "Remote",
|
||||||
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
||||||
"e4abe63a8b": "Connect to TailScale VPN network",
|
|
||||||
"0b86461350": "TailScale use xEdge server",
|
"0b86461350": "TailScale use xEdge server",
|
||||||
"a7d199ad4f": "Login URL:",
|
"a7d199ad4f": "Login URL:",
|
||||||
"3bef87ee46": "Wait to obtain the Login URL",
|
"3bef87ee46": "Wait to obtain the Login URL",
|
||||||
"a3060e541f": "Quitting...",
|
"a3060e541f": "Quitting...",
|
||||||
"0baef6200d": "Network IP",
|
|
||||||
"2faec1f9f8": "Enable",
|
"2faec1f9f8": "Enable",
|
||||||
"761c43fec5": "Connect to ZeroTier VPN network",
|
|
||||||
"3cc3bd7438": "Connecting...",
|
"3cc3bd7438": "Connecting...",
|
||||||
"5028274560": "Network ID",
|
"5028274560": "Network ID",
|
||||||
|
"0baef6200d": "Network IP",
|
||||||
"a2c4bef9fa": "Connect fail, please retry",
|
"a2c4bef9fa": "Connect fail, please retry",
|
||||||
"6327b4e59f": "Retry",
|
"6327b4e59f": "Retry",
|
||||||
"37cfbb7f44": "Enter ZeroTier Network ID",
|
"37cfbb7f44": "Enter ZeroTier Network ID",
|
||||||
"718f8fd90c": "Join in",
|
"718f8fd90c": "Join in",
|
||||||
"18c514b621": "Connect to EasyTier server",
|
"8fc22b3dac": "Network Node",
|
||||||
"a3e117fed1": "Network Name",
|
"a3e117fed1": "Network Name",
|
||||||
"5f1ffd341a": "Network Secret",
|
"5f1ffd341a": "Network Secret",
|
||||||
"8fc22b3dac": "Network Node",
|
|
||||||
"11a755d598": "Stop",
|
"11a755d598": "Stop",
|
||||||
"ce0be71e33": "Log",
|
"ce0be71e33": "Log",
|
||||||
"3119fca100": "Node Info",
|
"3119fca100": "Node Info",
|
||||||
"3b92996a28": "Enter EasyTier Network Name",
|
|
||||||
"a4c4c07b3d": "Enter EasyTier Network Secret",
|
|
||||||
"7a1920d611": "Default",
|
"7a1920d611": "Default",
|
||||||
"63e0339544": "Enter EasyTier Network Node",
|
"63e0339544": "Enter EasyTier Network Node",
|
||||||
|
"3b92996a28": "Enter EasyTier Network Name",
|
||||||
|
"a4c4c07b3d": "Enter EasyTier Network Secret",
|
||||||
"a6122a65ea": "Start",
|
"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",
|
"1bbabcdef3": "Edit frpc.toml",
|
||||||
"9c088a303a": "Enter frpc configuration",
|
"9c088a303a": "Enter frpc configuration",
|
||||||
"867cee98fd": "Passwords do not match",
|
"867cee98fd": "Passwords do not match",
|
||||||
@@ -291,9 +318,18 @@
|
|||||||
"5c43d74dbd": "Control the USB emulation state",
|
"5c43d74dbd": "Control the USB emulation state",
|
||||||
"f6c8ddbadf": "Disable USB Emulation",
|
"f6c8ddbadf": "Disable USB Emulation",
|
||||||
"020b92cfbb": "Enable 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",
|
"f43c0398a4": "Reset Configuration",
|
||||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||||
"0d784092e8": "Reset Config",
|
"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",
|
"a1c58e9422": "Appearance",
|
||||||
"d414b664a7": "Customize the look and feel of your KVM interface",
|
"d414b664a7": "Customize the look and feel of your KVM interface",
|
||||||
"d721757161": "Theme",
|
"d721757161": "Theme",
|
||||||
@@ -391,12 +427,21 @@
|
|||||||
"d4dccb8ca2": "Save settings",
|
"d4dccb8ca2": "Save settings",
|
||||||
"8750a898cb": "IPv4 Mode",
|
"8750a898cb": "IPv4 Mode",
|
||||||
"72c2543791": "Configure IPv4 mode",
|
"72c2543791": "Configure IPv4 mode",
|
||||||
|
"168d88811a": "Effective Upon Reboot",
|
||||||
|
"4805e7f806": "Request Address",
|
||||||
|
"536f68587c": "Netmask",
|
||||||
|
"926dec9494": "Gateway",
|
||||||
"94c252be0e": "DHCP Information",
|
"94c252be0e": "DHCP Information",
|
||||||
"902f16cd13": "No DHCP lease information available",
|
"902f16cd13": "No DHCP lease information available",
|
||||||
"6a802c3684": "IPv6 Mode",
|
"6a802c3684": "IPv6 Mode",
|
||||||
"d29b71c737": "Configure the IPv6 mode",
|
"d29b71c737": "Configure the IPv6 mode",
|
||||||
"d323009843": "No IPv6 addresses configured",
|
"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",
|
"697b29c12e": "Back to KVM",
|
||||||
"34e2d1989a": "Video",
|
"34e2d1989a": "Video",
|
||||||
"2c50ab9cb6": "Configure display settings and EDID for optimal compatibility",
|
"2c50ab9cb6": "Configure display settings and EDID for optimal compatibility",
|
||||||
@@ -411,6 +456,8 @@
|
|||||||
"c63ecd19a0": "Contrast",
|
"c63ecd19a0": "Contrast",
|
||||||
"5a31e20e6d": "Contrast level",
|
"5a31e20e6d": "Contrast level",
|
||||||
"4418069f82": "Reset to Default",
|
"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",
|
"25793bfcbb": "Adjust the EDID settings for the display",
|
||||||
"34d026e0a9": "Custom EDID",
|
"34d026e0a9": "Custom EDID",
|
||||||
"b0258b2bdb": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",
|
"b0258b2bdb": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"1281764038": "卸载",
|
"1281764038": "卸载",
|
||||||
"2188692750": "发送信号以唤醒通过 USB 连接的设备。",
|
"2188692750": "发送信号以唤醒通过 USB 连接的设备。",
|
||||||
|
"2191159446": "保存静态 IPv4 设置?",
|
||||||
|
"2316865986": "名称(可选)",
|
||||||
"3899981764": "你最多只能添加",
|
"3899981764": "你最多只能添加",
|
||||||
"514d8a494f": "终端",
|
"514d8a494f": "终端",
|
||||||
"ef4f580086": "粘贴文本",
|
"ef4f580086": "粘贴文本",
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
"a90c4aa86f": "确保 HDMI 线缆两端连接牢固",
|
"a90c4aa86f": "确保 HDMI 线缆两端连接牢固",
|
||||||
"6fd1357371": "确保源设备已开机并输出信号",
|
"6fd1357371": "确保源设备已开机并输出信号",
|
||||||
"3ea74f7e5e": "如果使用适配器,确保其兼容且工作正常",
|
"3ea74f7e5e": "如果使用适配器,确保其兼容且工作正常",
|
||||||
|
"db130e0e66": "部分主板不支持同时输出多路视频信号",
|
||||||
"54b1e31a57": "确保源设备未处于睡眠模式且正在输出信号",
|
"54b1e31a57": "确保源设备未处于睡眠模式且正在输出信号",
|
||||||
"693a670a35": "尝试唤醒",
|
"693a670a35": "尝试唤醒",
|
||||||
"d59048f21f": "了解更多",
|
"d59048f21f": "了解更多",
|
||||||
@@ -87,6 +90,9 @@
|
|||||||
"8fc3022cab": "点击视频以启用鼠标控制",
|
"8fc3022cab": "点击视频以启用鼠标控制",
|
||||||
"f200763a0d": "分离",
|
"f200763a0d": "分离",
|
||||||
"7193518e6f": "固定",
|
"7193518e6f": "固定",
|
||||||
|
"1c6a47f19f": "锁定模式",
|
||||||
|
"b2759484fd": "点击切换到直接触发模式",
|
||||||
|
"80461c2be2": "点击切换到锁定模式",
|
||||||
"703703879d": "IO 控制",
|
"703703879d": "IO 控制",
|
||||||
"4ae517d1d2": "配置您的 IO 输出电平状态",
|
"4ae517d1d2": "配置您的 IO 输出电平状态",
|
||||||
"258f49887e": "高电平",
|
"258f49887e": "高电平",
|
||||||
@@ -113,6 +119,7 @@
|
|||||||
"ae94be3cd5": "管理",
|
"ae94be3cd5": "管理",
|
||||||
"5fe0273ec6": "粘贴文本",
|
"5fe0273ec6": "粘贴文本",
|
||||||
"2a5a0639a5": "将文本从客户端粘贴到远程主机",
|
"2a5a0639a5": "将文本从客户端粘贴到远程主机",
|
||||||
|
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
|
||||||
"6d161c8084": "从主机粘贴",
|
"6d161c8084": "从主机粘贴",
|
||||||
"604c45fbf2": "以下字符将不会被粘贴:",
|
"604c45fbf2": "以下字符将不会被粘贴:",
|
||||||
"a7eb9efa0b": "使用键盘布局发送文本:",
|
"a7eb9efa0b": "使用键盘布局发送文本:",
|
||||||
@@ -217,33 +224,53 @@
|
|||||||
"3d0de21428": "更新设备访问密码",
|
"3d0de21428": "更新设备访问密码",
|
||||||
"f8508f576c": "远程",
|
"f8508f576c": "远程",
|
||||||
"a8bb6f5f9f": "管理远程访问设备的方式",
|
"a8bb6f5f9f": "管理远程访问设备的方式",
|
||||||
"e4abe63a8b": "连接到 TailScale VPN 网络",
|
|
||||||
"0b86461350": "TailScale 使用 xEdge 服务器",
|
"0b86461350": "TailScale 使用 xEdge 服务器",
|
||||||
"a7d199ad4f": "登录网址:",
|
"a7d199ad4f": "登录网址:",
|
||||||
"3bef87ee46": "等待获取登录网址",
|
"3bef87ee46": "等待获取登录网址",
|
||||||
"a3060e541f": "退出中...",
|
"a3060e541f": "退出中...",
|
||||||
"0baef6200d": "网络 IP",
|
|
||||||
"2faec1f9f8": "启用",
|
"2faec1f9f8": "启用",
|
||||||
"761c43fec5": "连接到 ZeroTier VPN 网络",
|
|
||||||
"3cc3bd7438": "连接中...",
|
"3cc3bd7438": "连接中...",
|
||||||
"5028274560": "网络 ID",
|
"5028274560": "网络 ID",
|
||||||
|
"0baef6200d": "网络 IP",
|
||||||
"a2c4bef9fa": "连接失败,请重试",
|
"a2c4bef9fa": "连接失败,请重试",
|
||||||
"6327b4e59f": "重试",
|
"6327b4e59f": "重试",
|
||||||
"37cfbb7f44": "输入 ZeroTier 网络 ID",
|
"37cfbb7f44": "输入 ZeroTier 网络 ID",
|
||||||
"718f8fd90c": "加入",
|
"718f8fd90c": "加入",
|
||||||
"18c514b621": "连接到 EasyTier 服务器",
|
"8fc22b3dac": "网络节点",
|
||||||
"a3e117fed1": "网络名称",
|
"a3e117fed1": "网络名称",
|
||||||
"5f1ffd341a": "网络密钥",
|
"5f1ffd341a": "网络密钥",
|
||||||
"8fc22b3dac": "网络节点",
|
|
||||||
"11a755d598": "停止",
|
"11a755d598": "停止",
|
||||||
"ce0be71e33": "日志",
|
"ce0be71e33": "日志",
|
||||||
"3119fca100": "节点信息",
|
"3119fca100": "节点信息",
|
||||||
"3b92996a28": "输入 EasyTier 网络名称",
|
|
||||||
"a4c4c07b3d": "输入 EasyTier 网络密钥",
|
|
||||||
"7a1920d611": "默认",
|
"7a1920d611": "默认",
|
||||||
"63e0339544": "输入 EasyTier 网络节点",
|
"63e0339544": "输入 EasyTier 网络节点",
|
||||||
|
"3b92996a28": "输入 EasyTier 网络名称",
|
||||||
|
"a4c4c07b3d": "输入 EasyTier 网络密钥",
|
||||||
"a6122a65ea": "启动",
|
"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",
|
"1bbabcdef3": "编辑 frpc.toml",
|
||||||
"9c088a303a": "输入 frpc 配置",
|
"9c088a303a": "输入 frpc 配置",
|
||||||
"867cee98fd": "密码不一致",
|
"867cee98fd": "密码不一致",
|
||||||
@@ -291,9 +318,18 @@
|
|||||||
"5c43d74dbd": "控制 USB 复用状态",
|
"5c43d74dbd": "控制 USB 复用状态",
|
||||||
"f6c8ddbadf": "禁用 USB 复用",
|
"f6c8ddbadf": "禁用 USB 复用",
|
||||||
"020b92cfbb": "启用 USB 复用",
|
"020b92cfbb": "启用 USB 复用",
|
||||||
|
"9f55f64b0f": "USB 设备重新初始化",
|
||||||
|
"40dc677a89": "重新初始化 USB 设备配置",
|
||||||
|
"02d2f33ec9": "重新初始化 USB 设备",
|
||||||
|
"f5ddf02991": "重启系统",
|
||||||
|
"1dbbf194af": "重启设备系统",
|
||||||
|
"1de72c4fc6": "重启",
|
||||||
"f43c0398a4": "重置配置",
|
"f43c0398a4": "重置配置",
|
||||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||||
"0d784092e8": "重置配置",
|
"0d784092e8": "重置配置",
|
||||||
|
"a776e925bf": "重启系统?",
|
||||||
|
"1f070051ff": "你确定重启系统吗?",
|
||||||
|
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||||
"a1c58e9422": "外观",
|
"a1c58e9422": "外观",
|
||||||
"d414b664a7": "自定义 KVM 界面的外观",
|
"d414b664a7": "自定义 KVM 界面的外观",
|
||||||
"d721757161": "主题",
|
"d721757161": "主题",
|
||||||
@@ -391,12 +427,21 @@
|
|||||||
"d4dccb8ca2": "保存设置",
|
"d4dccb8ca2": "保存设置",
|
||||||
"8750a898cb": "IPv4 模式",
|
"8750a898cb": "IPv4 模式",
|
||||||
"72c2543791": "配置 IPv4 模式",
|
"72c2543791": "配置 IPv4 模式",
|
||||||
|
"168d88811a": "重启后生效",
|
||||||
|
"4805e7f806": "申请地址",
|
||||||
|
"536f68587c": "网络掩码",
|
||||||
|
"926dec9494": "网关",
|
||||||
"94c252be0e": "DHCP 信息",
|
"94c252be0e": "DHCP 信息",
|
||||||
"902f16cd13": "无 DHCP 租约信息",
|
"902f16cd13": "无 DHCP 租约信息",
|
||||||
"6a802c3684": "IPv6 模式",
|
"6a802c3684": "IPv6 模式",
|
||||||
"d29b71c737": "配置 IPv6 模式",
|
"d29b71c737": "配置 IPv6 模式",
|
||||||
"d323009843": "未配置 IPv6 地址",
|
"d323009843": "未配置 IPv6 地址",
|
||||||
"892eec6b1a": "这将请求 DHCP 服务器分配新的 IP 地址。在此过程中,您的设备可能会失去网络连接。",
|
"676228894e": "更改将在重启后生效。",
|
||||||
|
"70d9be9b13": "确认",
|
||||||
|
"50cfb85440": "保存请求的 IPv4 地址?",
|
||||||
|
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
||||||
|
"cc172c234b": "更改 IPv4 模式?",
|
||||||
|
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
||||||
"697b29c12e": "返回 KVM",
|
"697b29c12e": "返回 KVM",
|
||||||
"34e2d1989a": "视频",
|
"34e2d1989a": "视频",
|
||||||
"2c50ab9cb6": "配置视频显示和 EDID",
|
"2c50ab9cb6": "配置视频显示和 EDID",
|
||||||
@@ -411,6 +456,8 @@
|
|||||||
"c63ecd19a0": "对比度",
|
"c63ecd19a0": "对比度",
|
||||||
"5a31e20e6d": "对比度级别",
|
"5a31e20e6d": "对比度级别",
|
||||||
"4418069f82": "恢复默认",
|
"4418069f82": "恢复默认",
|
||||||
|
"839b2e1447": "强制 EDID 输出",
|
||||||
|
"66e3bd652e": "强制 EDID 输出,即使没有连接显示器",
|
||||||
"25793bfcbb": "调整显示器的 EDID 设置",
|
"25793bfcbb": "调整显示器的 EDID 设置",
|
||||||
"34d026e0a9": "自定义 EDID",
|
"34d026e0a9": "自定义 EDID",
|
||||||
"b0258b2bdb": "EDID 详细信息视频模式兼容性。默认设置在大多数情况下有效,但某些独特的 UEFI/BIOS 可能需要调整。",
|
"b0258b2bdb": "EDID 详细信息视频模式兼容性。默认设置在大多数情况下有效,但某些独特的 UEFI/BIOS 可能需要调整。",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import Checkbox from "../components/Checkbox";
|
|||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import { TextAreaWithLabel } from "../components/TextArea";
|
import { TextAreaWithLabel } from "../components/TextArea";
|
||||||
import { useSettingsStore } from "../hooks/stores";
|
import { useSettingsStore, useHidStore } from "../hooks/stores";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
@@ -24,9 +24,12 @@ export default function SettingsAdvancedRoute() {
|
|||||||
const [devChannel, setDevChannel] = useState(false);
|
const [devChannel, setDevChannel] = useState(false);
|
||||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||||
|
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
||||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getSSHKeyState", {}, resp => {
|
send("getSSHKeyState", {}, resp => {
|
||||||
@@ -209,6 +212,47 @@ export default function SettingsAdvancedRoute() {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</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
|
<SettingsItem
|
||||||
title={$at("Reset Configuration")}
|
title={$at("Reset Configuration")}
|
||||||
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
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"
|
confirmText="I Understand, Enable Anyway"
|
||||||
onConfirm={confirmLoopbackModeEnable}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import { LuEthernetPort } from "react-icons/lu";
|
import { LuEthernetPort } from "react-icons/lu";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IPv4Mode,
|
IPv4Mode,
|
||||||
|
IPv4StaticConfig,
|
||||||
IPv6Mode,
|
IPv6Mode,
|
||||||
LLDPMode,
|
LLDPMode,
|
||||||
mDNSMode,
|
mDNSMode,
|
||||||
@@ -84,11 +86,25 @@ export default function SettingsNetworkRoute() {
|
|||||||
|
|
||||||
// We use this to determine whether the settings have changed
|
// We use this to determine whether the settings have changed
|
||||||
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
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 [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
||||||
|
|
||||||
const [customDomain, setCustomDomain] = useState<string>("");
|
const [customDomain, setCustomDomain] = useState<string>("");
|
||||||
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("local");
|
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(() => {
|
useEffect(() => {
|
||||||
if (networkSettings.domain && networkSettingsLoaded) {
|
if (networkSettings.domain && networkSettingsLoaded) {
|
||||||
@@ -109,10 +125,33 @@ export default function SettingsNetworkRoute() {
|
|||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
console.log(resp.result);
|
console.log(resp.result);
|
||||||
setNetworkSettings(resp.result as NetworkSettings);
|
setNetworkSettings(resp.result as NetworkSettings);
|
||||||
|
|
||||||
if (!firstNetworkSettings.current) {
|
if (!firstNetworkSettings.current) {
|
||||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
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);
|
setNetworkSettingsLoaded(true);
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
@@ -164,7 +203,41 @@ export default function SettingsNetworkRoute() {
|
|||||||
}, [getNetworkState, getNetworkSettings]);
|
}, [getNetworkState, getNetworkSettings]);
|
||||||
|
|
||||||
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
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) => {
|
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
|
||||||
@@ -212,6 +285,55 @@ export default function SettingsNetworkRoute() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -337,7 +459,10 @@ export default function SettingsNetworkRoute() {
|
|||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
disabled={firstNetworkSettings.current === networkSettings}
|
disabled={
|
||||||
|
firstNetworkSettings.current === networkSettings ||
|
||||||
|
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
|
||||||
|
}
|
||||||
text={$at("Save settings")}
|
text={$at("Save settings")}
|
||||||
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||||
/>
|
/>
|
||||||
@@ -346,46 +471,135 @@ export default function SettingsNetworkRoute() {
|
|||||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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
|
<SelectMenuBasic
|
||||||
size="SM"
|
size="SM"
|
||||||
value={networkSettings.ipv4_mode}
|
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([
|
options={filterUnknown([
|
||||||
{ value: "dhcp", label: "DHCP" },
|
{ value: "dhcp", label: "DHCP" },
|
||||||
// { value: "static", label: "Static" },
|
{ value: "static", label: "Static" },
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
<AutoHeight>
|
|
||||||
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
|
{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>
|
<GridCard>
|
||||||
<div className="p-4">
|
<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">
|
||||||
<div className="space-y-4">
|
<InputFieldWithLabel
|
||||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
size="SM"
|
||||||
{$at("DHCP Lease Information")}
|
type="text"
|
||||||
</h3>
|
label={$at("IP Address")}
|
||||||
<div className="animate-pulse space-y-3">
|
placeholder="192.168.1.100"
|
||||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
value={networkSettings.ipv4_static?.address || ""}
|
||||||
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
onChange={e => {
|
||||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
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>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||||
|
{$at("DHCP Lease Information")}
|
||||||
|
</h3>
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GridCard>
|
||||||
</GridCard>
|
) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? (
|
||||||
) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? (
|
<DhcpLeaseCard
|
||||||
<DhcpLeaseCard
|
networkState={networkState}
|
||||||
networkState={networkState}
|
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
||||||
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<EmptyCard
|
||||||
<EmptyCard
|
IconElm={LuEthernetPort}
|
||||||
IconElm={LuEthernetPort}
|
headline={$at("DHCP Information")}
|
||||||
headline={$at("DHCP Information")}
|
description={$at("No DHCP lease information available")}
|
||||||
description={$at("No DHCP lease information available")}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</AutoHeight>
|
||||||
</AutoHeight>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem title={$at("IPv6 Mode")} description={$at("Configure the IPv6 mode")}>
|
<SettingsItem title={$at("IPv6 Mode")} description={$at("Configure the IPv6 mode")}>
|
||||||
@@ -453,7 +667,7 @@ export default function SettingsNetworkRoute() {
|
|||||||
open={showRenewLeaseConfirm}
|
open={showRenewLeaseConfirm}
|
||||||
onClose={() => setShowRenewLeaseConfirm(false)}
|
onClose={() => setShowRenewLeaseConfirm(false)}
|
||||||
title={$at("Renew DHCP Lease")}
|
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"
|
variant="danger"
|
||||||
confirmText={$at("Renew DHCP Lease")}
|
confirmText={$at("Renew DHCP Lease")}
|
||||||
cancelText={$at("Cancel")}
|
cancelText={$at("Cancel")}
|
||||||
@@ -462,6 +676,64 @@ export default function SettingsNetworkRoute() {
|
|||||||
setShowRenewLeaseConfirm(false);
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from "@/hooks/stores";
|
|||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||||
|
import Checkbox from "../components/Checkbox";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
import {useReactAt} from 'i18n-auto-extractor/react'
|
import {useReactAt} from 'i18n-auto-extractor/react'
|
||||||
@@ -48,6 +49,7 @@ export default function SettingsVideoRoute() {
|
|||||||
const [streamQuality, setStreamQuality] = useState("1");
|
const [streamQuality, setStreamQuality] = useState("1");
|
||||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||||
const [edid, setEdid] = useState<string | null>(null);
|
const [edid, setEdid] = useState<string | null>(null);
|
||||||
|
const [forceHpd, setForceHpd] = useState(false);
|
||||||
|
|
||||||
// Video enhancement settings from store
|
// Video enhancement settings from store
|
||||||
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
||||||
@@ -85,7 +87,31 @@ export default function SettingsVideoRoute() {
|
|||||||
setCustomEdidValue(receivedEdid);
|
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]);
|
}, [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) => {
|
const handleStreamQualityChange = (factor: string) => {
|
||||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||||
@@ -205,6 +231,17 @@ export default function SettingsVideoRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<SettingsItem
|
||||||
title="EDID"
|
title="EDID"
|
||||||
description={$at("Adjust the EDID settings for the display")}
|
description={$at("Adjust the EDID settings for the display")}
|
||||||
|
|||||||
@@ -566,12 +566,13 @@ export default function KvmIdRoute() {
|
|||||||
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
|
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
|
||||||
|
|
||||||
const setUsbState = useHidStore(state => state.setUsbState);
|
const setUsbState = useHidStore(state => state.setUsbState);
|
||||||
|
const usbState = useHidStore(state => state.usbState);
|
||||||
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
||||||
|
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||||
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
|
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
|
||||||
|
|
||||||
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
|
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
|
||||||
|
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
|
||||||
|
|
||||||
const [hasUpdated, setHasUpdated] = useState(false);
|
const [hasUpdated, setHasUpdated] = useState(false);
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
@@ -601,6 +602,42 @@ export default function KvmIdRoute() {
|
|||||||
setKeyboardLedStateSyncAvailable(true);
|
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") {
|
if (resp.method === "otaState") {
|
||||||
const otaState = resp.params as UpdateState["otaState"];
|
const otaState = resp.params as UpdateState["otaState"];
|
||||||
setOtaState(otaState);
|
setOtaState(otaState);
|
||||||
@@ -624,6 +661,12 @@ export default function KvmIdRoute() {
|
|||||||
window.location.href = currentUrl.toString();
|
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);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|||||||
109
usb.go
109
usb.go
@@ -1,6 +1,7 @@
|
|||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"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
|
// open the keyboard hid file to listen for keyboard events
|
||||||
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
||||||
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
|
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
|
||||||
@@ -136,3 +152,96 @@ func rpcSendUsbWakeupSignal() error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ func updateMtpWithSDStatus() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if config.UsbDevices.Mtp {
|
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")
|
logger.Error().Err(err).Msg("failed to unbind UDC")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
video.go
5
video.go
@@ -49,3 +49,8 @@ func HandleVideoStateMessage(event CtrlResponse) {
|
|||||||
func rpcGetVideoState() (VideoInputState, error) {
|
func rpcGetVideoState() (VideoInputState, error) {
|
||||||
return lastVideoState, nil
|
return lastVideoState, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setForceHpd() error {
|
||||||
|
err := rpcSetForceHpd(config.ForceHpd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
277
vpn.go
277
vpn.go
@@ -381,6 +381,95 @@ func rpcGetFrpcStatus() (FrpcStatus, error) {
|
|||||||
return FrpcStatus{Running: frpcRunning()}, nil
|
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 {
|
type EasytierStatus struct {
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
}
|
}
|
||||||
@@ -504,6 +593,175 @@ func rpcGetEasyTierStatus() (EasytierStatus, error) {
|
|||||||
return EasytierStatus{Running: easytierRunning()}, nil
|
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() {
|
func initVPN() {
|
||||||
waitVpnCtrlClientConnected()
|
waitVpnCtrlClientConnected()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -540,6 +798,25 @@ func initVPN() {
|
|||||||
vpnLogger.Error().Err(err).Msg("Failed to auto start easytier")
|
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() {
|
go func() {
|
||||||
|
|||||||
Reference in New Issue
Block a user