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)
|
||||
BUILDTS ?= $(shell date -u +%s)
|
||||
REVISION ?= $(shell git rev-parse HEAD)
|
||||
VERSION_DEV ?= 0.0.3-dev
|
||||
VERSION ?= 0.0.3
|
||||
VERSION_DEV ?= 0.0.4-dev
|
||||
VERSION ?= 0.0.4
|
||||
|
||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
KVM_PKG_NAME := kvm
|
||||
|
||||
20
config.go
20
config.go
@@ -92,6 +92,7 @@ type Config struct {
|
||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||
KeyboardLayout string `json:"keyboard_layout"`
|
||||
EdidString string `json:"hdmi_edid_string"`
|
||||
ForceHpd bool `json:"force_hpd"` // 强制输出EDID
|
||||
ActiveExtension string `json:"active_extension"`
|
||||
DisplayRotation string `json:"display_rotation"`
|
||||
DisplayMaxBrightness int `json:"display_max_brightness"`
|
||||
@@ -101,6 +102,7 @@ type Config struct {
|
||||
UsbConfig *usbgadget.Config `json:"usb_config"`
|
||||
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
||||
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
||||
AppliedNetworkConfig *network.NetworkConfig `json:"applied_network_config,omitempty"`
|
||||
DefaultLogLevel string `json:"default_log_level"`
|
||||
TailScaleAutoStart bool `json:"tailscale_autostart"`
|
||||
TailScaleXEdge bool `json:"tailscale_xedge"`
|
||||
@@ -108,6 +110,8 @@ type Config struct {
|
||||
ZeroTierAutoStart bool `json:"zerotier_autostart"`
|
||||
FrpcAutoStart bool `json:"frpc_autostart"`
|
||||
FrpcToml string `json:"frpc_toml"`
|
||||
CloudflaredAutoStart bool `json:"cloudflared_autostart"`
|
||||
CloudflaredToken string `json:"cloudflared_token"`
|
||||
IO0Status bool `json:"io0_status"`
|
||||
IO1Status bool `json:"io1_status"`
|
||||
AudioMode string `json:"audio_mode"`
|
||||
@@ -117,6 +121,19 @@ type Config struct {
|
||||
AutoMountSystemInfo bool `json:"auto_mount_system_info_img"`
|
||||
EasytierAutoStart bool `json:"easytier_autostart"`
|
||||
EasytierConfig EasytierConfig `json:"easytier_config"`
|
||||
VntAutoStart bool `json:"vnt_autostart"`
|
||||
VntConfig VntConfig `json:"vnt_config"`
|
||||
}
|
||||
|
||||
type VntConfig struct {
|
||||
Token string `json:"token"`
|
||||
DeviceId string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ConfigMode string `json:"config_mode"` // "params" or "file"
|
||||
ConfigFile string `json:"config_file"`
|
||||
Model string `json:"model"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
const configPath = "/userdata/kvm_config.json"
|
||||
@@ -134,6 +151,7 @@ var defaultConfig = &Config{
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
TLSMode: "",
|
||||
ForceHpd: false, // 默认不强制输出EDID
|
||||
UsbConfig: &usbgadget.Config{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
@@ -150,11 +168,13 @@ var defaultConfig = &Config{
|
||||
Mtp: false,
|
||||
},
|
||||
NetworkConfig: &network.NetworkConfig{},
|
||||
AppliedNetworkConfig: nil,
|
||||
DefaultLogLevel: "INFO",
|
||||
ZeroTierAutoStart: false,
|
||||
TailScaleAutoStart: false,
|
||||
TailScaleXEdge: false,
|
||||
FrpcAutoStart: false,
|
||||
CloudflaredAutoStart: false,
|
||||
IO0Status: true,
|
||||
IO1Status: true,
|
||||
AudioMode: "disabled",
|
||||
|
||||
@@ -345,15 +345,35 @@ func (f *FieldConfig) validateField() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := f.CurrentValue.(type) {
|
||||
case []string:
|
||||
// Validate each element in the slice
|
||||
for _, item := range v {
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if err := f.validateScalarValue(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
val, err := toString(f.CurrentValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
|
||||
}
|
||||
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
if err := f.validateScalarValue(val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScalarValue applies ValidateTypes to a single string value.
|
||||
func (f *FieldConfig) validateScalarValue(val string) error {
|
||||
for _, validateType := range f.ValidateTypes {
|
||||
switch validateType {
|
||||
case "ipv4":
|
||||
@@ -376,6 +396,5 @@ func (f *FieldConfig) validateField() error {
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,22 @@ func toString(v interface{}) (string, error) {
|
||||
return v, nil
|
||||
case null.String:
|
||||
return v.String, nil
|
||||
case []string:
|
||||
if len(v) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(v) == 1 {
|
||||
return v[0], nil
|
||||
}
|
||||
return strings.Join(v, ","), nil
|
||||
case []interface{}:
|
||||
if len(v) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if s, ok := v[0].(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported type in slice: %T", v[0])
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v))
|
||||
|
||||
@@ -50,6 +50,7 @@ type NetworkConfig struct {
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
|
||||
}
|
||||
|
||||
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||
|
||||
@@ -3,6 +3,9 @@ package network
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kvm/internal/confparser"
|
||||
@@ -100,6 +103,31 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
|
||||
|
||||
s.dhcpClient = dhcpClient
|
||||
|
||||
mode := strings.TrimSpace(s.config.IPv4Mode.String)
|
||||
switch mode {
|
||||
case "static":
|
||||
if s.dhcpClient != nil {
|
||||
s.dhcpClient.SetEnabled(false)
|
||||
}
|
||||
if err := s.applyIPv4Static(); err != nil {
|
||||
l.Error().Err(err).Msg("failed to apply static IPv4 on init")
|
||||
}
|
||||
case "dhcp":
|
||||
if s.dhcpClient != nil {
|
||||
s.dhcpClient.SetEnabled(true)
|
||||
}
|
||||
case "disabled":
|
||||
if s.dhcpClient != nil {
|
||||
s.dhcpClient.SetEnabled(false)
|
||||
}
|
||||
if err := s.clearIPv4Addresses(); err != nil {
|
||||
l.Warn().Err(err).Msg("failed to clear IPv4 addresses on init")
|
||||
}
|
||||
if err := s.clearDefaultIPv4Route(); err != nil {
|
||||
l.Debug().Err(err).Msg("failed to clear default route on init")
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -346,3 +374,159 @@ func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
|
||||
_ = s.setHostnameIfNotSame()
|
||||
s.cbConfigChange(config)
|
||||
}
|
||||
|
||||
// clearIPv4Addresses removes all IPv4 addresses from the interface.
|
||||
func (s *NetworkInterfaceState) clearIPv4Addresses() error {
|
||||
iface, err := netlink.LinkByName(s.interfaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addrs, err := netlinkAddrs(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.To4() != nil {
|
||||
if err := netlink.AddrDel(iface, &addr); err != nil {
|
||||
s.l.Warn().Err(err).Str("addr", addr.IPNet.String()).Msg("failed to delete IPv4 address")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearDefaultIPv4Route removes existing default IPv4 route on this interface.
|
||||
func (s *NetworkInterfaceState) clearDefaultIPv4Route() error {
|
||||
iface, err := netlink.LinkByName(s.interfaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
routes, err := netlink.RouteList(iface, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range routes {
|
||||
if r.Dst == nil { // default route
|
||||
if err := netlink.RouteDel(&r); err != nil {
|
||||
s.l.Warn().Err(err).Msg("failed to delete default route")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseIPv4Mask converts dotted decimal netmask to net.IPMask.
|
||||
func parseIPv4Mask(mask string) (net.IPMask, error) {
|
||||
parts := strings.Split(strings.TrimSpace(mask), ".")
|
||||
if len(parts) != 4 {
|
||||
return nil, fmt.Errorf("invalid netmask: %s", mask)
|
||||
}
|
||||
bytes := make([]byte, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
v, err := strconv.Atoi(parts[i])
|
||||
if err != nil || v < 0 || v > 255 {
|
||||
return nil, fmt.Errorf("invalid netmask octet: %s", parts[i])
|
||||
}
|
||||
bytes[i] = byte(v)
|
||||
}
|
||||
return net.IPv4Mask(bytes[0], bytes[1], bytes[2], bytes[3]), nil
|
||||
}
|
||||
|
||||
// writeResolvConf writes DNS servers and optional domain into /etc/resolv.conf.
|
||||
func (s *NetworkInterfaceState) writeResolvConf(dns []string, domain string) error {
|
||||
var b strings.Builder
|
||||
if domain != "" {
|
||||
b.WriteString("search ")
|
||||
b.WriteString(domain)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, d := range dns {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("nameserver ")
|
||||
b.WriteString(d)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
content := b.String()
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile("/etc/resolv.conf", []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
s.l.Info().Msg("updated /etc/resolv.conf for static IPv4")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyIPv4Static sets a static IPv4 address, default route, and DNS.
|
||||
func (s *NetworkInterfaceState) applyIPv4Static() error {
|
||||
if s.config == nil || s.config.IPv4Static == nil {
|
||||
return fmt.Errorf("IPv4Static config not provided")
|
||||
}
|
||||
ipStr := strings.TrimSpace(s.config.IPv4Static.Address.String)
|
||||
maskStr := strings.TrimSpace(s.config.IPv4Static.Netmask.String)
|
||||
gwStr := strings.TrimSpace(s.config.IPv4Static.Gateway.String)
|
||||
dns := s.config.IPv4Static.DNS
|
||||
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil || ip.To4() == nil {
|
||||
return fmt.Errorf("invalid IPv4 address: %s", ipStr)
|
||||
}
|
||||
mask, err := parseIPv4Mask(maskStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
iface, err := netlink.LinkByName(s.interfaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear existing IPv4 addresses and default route
|
||||
if err := s.clearIPv4Addresses(); err != nil {
|
||||
s.l.Warn().Err(err).Msg("failed clearing IPv4 addresses prior to static apply")
|
||||
}
|
||||
if err := s.clearDefaultIPv4Route(); err != nil {
|
||||
s.l.Warn().Err(err).Msg("failed clearing default route prior to static apply")
|
||||
}
|
||||
|
||||
ipNet := &net.IPNet{IP: ip, Mask: mask}
|
||||
addr := &netlink.Addr{IPNet: ipNet}
|
||||
if err := netlink.AddrAdd(iface, addr); err != nil {
|
||||
return logging.ErrorfL(s.l, "failed to add static IPv4 address", err)
|
||||
}
|
||||
s.l.Info().Str("ipv4", ipNet.String()).Msg("static IPv4 address applied")
|
||||
|
||||
// Default route
|
||||
if gwStr != "" {
|
||||
gw := net.ParseIP(gwStr)
|
||||
if gw == nil || gw.To4() == nil {
|
||||
s.l.Warn().Str("gateway", gwStr).Msg("invalid IPv4 gateway; skipping route")
|
||||
} else {
|
||||
route := netlink.Route{LinkIndex: iface.Attrs().Index, Gw: gw}
|
||||
// remove any existing default routes already attempted above, then add
|
||||
if err := netlink.RouteAdd(&route); err != nil {
|
||||
// try replace if add failed
|
||||
if replaceErr := netlink.RouteReplace(&route); replaceErr != nil {
|
||||
s.l.Warn().Err(err).Msg("failed to add default route")
|
||||
}
|
||||
}
|
||||
s.l.Info().Str("gateway", gwStr).Msg("default route applied")
|
||||
}
|
||||
}
|
||||
|
||||
// DNS
|
||||
if len(dns) > 0 {
|
||||
if err := s.writeResolvConf(dns, s.GetDomain()); err != nil {
|
||||
s.l.Warn().Err(err).Msg("failed to write resolv.conf")
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh internal state
|
||||
if _, err := s.update(); err != nil {
|
||||
s.l.Warn().Err(err).Msg("failed to refresh state after static apply")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"kvm/internal/confparser"
|
||||
"kvm/internal/udhcpc"
|
||||
|
||||
"github.com/guregu/null/v6"
|
||||
)
|
||||
|
||||
type RpcIPv6Address struct {
|
||||
@@ -106,7 +108,12 @@ func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSetting
|
||||
return err
|
||||
}
|
||||
|
||||
if IsSame(currentSettings, settings.NetworkConfig) {
|
||||
neutralA := *currentSettings
|
||||
neutralB := settings.NetworkConfig
|
||||
neutralA.PendingReboot = null.Bool{}
|
||||
neutralB.PendingReboot = null.Bool{}
|
||||
|
||||
if IsSame(neutralA, neutralB) {
|
||||
// no changes, do nothing
|
||||
return nil
|
||||
}
|
||||
@@ -124,3 +131,10 @@ func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
|
||||
|
||||
return s.dhcpClient.Renew()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) RpcRequestDHCPAddress(ip string) error {
|
||||
if s.dhcpClient == nil {
|
||||
return fmt.Errorf("dhcp client not initialized")
|
||||
}
|
||||
return s.dhcpClient.RequestAddress(ip)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type DHCPClient struct {
|
||||
logger *zerolog.Logger
|
||||
process *os.Process
|
||||
onLeaseChange func(lease *Lease)
|
||||
enabled bool
|
||||
}
|
||||
|
||||
type DHCPClientOptions struct {
|
||||
@@ -57,6 +58,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
|
||||
pidFile: options.PidFile,
|
||||
onLeaseChange: options.OnLeaseChange,
|
||||
requestAddress: options.RequestAddress,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +89,12 @@ func (c *DHCPClient) watchLink() {
|
||||
for update := range ch {
|
||||
if update.Link.Attrs().Name == c.InterfaceName {
|
||||
if update.Flags&unix.IFF_RUNNING != 0 {
|
||||
if c.enabled {
|
||||
c.logger.Info().Msg("link is up, starting udhcpc")
|
||||
go c.runUDHCPC()
|
||||
} else {
|
||||
c.logger.Debug().Msg("link is up, DHCP disabled")
|
||||
}
|
||||
} else {
|
||||
c.logger.Info().Msg("link is down")
|
||||
}
|
||||
@@ -110,6 +116,10 @@ func (w *udhcpcOutput) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
func (c *DHCPClient) runUDHCPC() {
|
||||
if !c.enabled {
|
||||
c.logger.Debug().Msg("DHCP disabled; skipping udhcpc start")
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1")
|
||||
if c.requestAddress != "" {
|
||||
ip := net.ParseIP(c.requestAddress)
|
||||
@@ -273,3 +283,29 @@ func (c *DHCPClient) loadLeaseFile() error {
|
||||
func (c *DHCPClient) GetLease() *Lease {
|
||||
return c.lease
|
||||
}
|
||||
|
||||
// RequestAddress updates the requested IPv4 address and restarts udhcpc with -r <ip>.
|
||||
func (c *DHCPClient) RequestAddress(ip string) error {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil || parsed.To4() == nil {
|
||||
return fmt.Errorf("invalid IPv4 address: %s", ip)
|
||||
}
|
||||
c.requestAddress = ip
|
||||
_ = c.KillProcess()
|
||||
go c.runUDHCPC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEnabled toggles DHCP client behavior. When enabling, it will attempt to start udhcpc.
|
||||
// When disabling, it kills any running udhcpc process.
|
||||
func (c *DHCPClient) SetEnabled(enable bool) {
|
||||
if c.enabled == enable {
|
||||
return
|
||||
}
|
||||
c.enabled = enable
|
||||
if enable {
|
||||
go c.runUDHCPC()
|
||||
} else {
|
||||
_ = c.KillProcess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/procfs"
|
||||
@@ -406,33 +407,79 @@ func (c *ChangeSet) ApplyChanges() error {
|
||||
}
|
||||
|
||||
func (c *ChangeSet) applyChange(change *FileChange) error {
|
||||
// 记录操作详情
|
||||
contentPreview := ""
|
||||
if len(change.ExpectedContent) > 0 && len(change.ExpectedContent) <= 64 {
|
||||
contentPreview = string(change.ExpectedContent)
|
||||
} else if len(change.ExpectedContent) > 64 {
|
||||
contentPreview = string(change.ExpectedContent[:64]) + "..."
|
||||
}
|
||||
|
||||
defaultLogger.Debug().
|
||||
Str("operation", FileChangeResolvedActionString[change.Action()]).
|
||||
Str("path", change.Path).
|
||||
Str("content_preview", contentPreview).
|
||||
Int("content_length", len(change.ExpectedContent)).
|
||||
Msg("executing file operation")
|
||||
|
||||
switch change.Action() {
|
||||
case FileChangeResolvedActionWriteFile:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("writing file")
|
||||
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
||||
case FileChangeResolvedActionUpdateFile:
|
||||
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("updating file")
|
||||
err := os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
||||
if err != nil && strings.Contains(err.Error(), "device or resource busy") {
|
||||
defaultLogger.Error().
|
||||
Str("path", change.Path).
|
||||
Str("content", contentPreview).
|
||||
Msg("device or resource busy - gadget may be bound to UDC")
|
||||
return fmt.Errorf("%w (hint: gadget may be bound to UDC, try unbinding first)", err)
|
||||
}
|
||||
return err
|
||||
case FileChangeResolvedActionCreateFile:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("creating file")
|
||||
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
|
||||
case FileChangeResolvedActionCreateSymlink:
|
||||
return os.Symlink(string(change.ExpectedContent), change.Path)
|
||||
target := string(change.ExpectedContent)
|
||||
defaultLogger.Debug().
|
||||
Str("path", change.Path).
|
||||
Str("target", target).
|
||||
Msg("creating symlink")
|
||||
return os.Symlink(target, change.Path)
|
||||
case FileChangeResolvedActionRecreateSymlink:
|
||||
target := string(change.ExpectedContent)
|
||||
defaultLogger.Debug().
|
||||
Str("path", change.Path).
|
||||
Str("target", target).
|
||||
Msg("recreating symlink")
|
||||
if err := os.Remove(change.Path); err != nil {
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
}
|
||||
return os.Symlink(string(change.ExpectedContent), change.Path)
|
||||
return os.Symlink(target, change.Path)
|
||||
case FileChangeResolvedActionReorderSymlinks:
|
||||
defaultLogger.Debug().
|
||||
Str("path", change.Path).
|
||||
Int("symlink_count", len(change.ParamSymlinks)).
|
||||
Msg("reordering symlinks")
|
||||
return recreateSymlinks(change, nil)
|
||||
case FileChangeResolvedActionCreateDirectory:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("creating directory")
|
||||
return os.MkdirAll(change.Path, 0755)
|
||||
case FileChangeResolvedActionRemove:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("removing file")
|
||||
return os.Remove(change.Path)
|
||||
case FileChangeResolvedActionRemoveDirectory:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("removing directory")
|
||||
return os.RemoveAll(change.Path)
|
||||
case FileChangeResolvedActionTouch:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("touching file")
|
||||
return os.Chtimes(change.Path, time.Now(), time.Now())
|
||||
case FileChangeResolvedActionMountConfigFS:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("mounting configfs")
|
||||
return mountConfigFS(change.Path)
|
||||
case FileChangeResolvedActionMountFunctionFS:
|
||||
defaultLogger.Debug().Str("path", change.Path).Msg("mounting functionfs")
|
||||
return mountFunctionFS(change.Path)
|
||||
case FileChangeResolvedActionDoNothing:
|
||||
return nil
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type gadgetConfigItem struct {
|
||||
@@ -238,19 +240,73 @@ func (u *UsbGadget) Init() error {
|
||||
|
||||
udcs := getUdcs()
|
||||
if len(udcs) < 1 {
|
||||
u.log.Warn().Msg("no UDC found, skipping USB stack init")
|
||||
return u.logWarn("no udc found, skipping USB stack init", nil)
|
||||
}
|
||||
|
||||
u.udc = udcs[0]
|
||||
|
||||
if err := u.ensureGadgetUnbound(); err != nil {
|
||||
u.log.Warn().Err(err).Msg("failed to ensure gadget is unbound, will continue")
|
||||
} else {
|
||||
u.log.Info().Msg("gadget unbind check completed")
|
||||
}
|
||||
|
||||
err := u.configureUsbGadget(false)
|
||||
if err != nil {
|
||||
u.log.Error().Err(err).
|
||||
Str("udc", u.udc).
|
||||
Interface("enabled_devices", u.enabledDevices).
|
||||
Msg("USB gadget initialization FAILED")
|
||||
return u.logError("unable to initialize USB stack", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) ensureGadgetUnbound() error {
|
||||
udcPath := path.Join(u.kvmGadgetPath, "UDC")
|
||||
|
||||
if _, err := os.Stat(u.kvmGadgetPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
udcContent, err := os.ReadFile(udcPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read UDC file: %w", err)
|
||||
}
|
||||
|
||||
currentUDC := strings.TrimSpace(string(udcContent))
|
||||
if currentUDC == "" || currentUDC == "none" {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.log.Info().
|
||||
Str("current_udc", currentUDC).
|
||||
Str("target_udc", u.udc).
|
||||
Msg("unbinding existing UDC before reconfiguration")
|
||||
|
||||
if err := u.UnbindUDC(); err != nil {
|
||||
u.log.Warn().Err(err).Msg("failed to unbind via UDC file, trying DWC3")
|
||||
if err := u.UnbindUDCToDWC3(); err != nil {
|
||||
return fmt.Errorf("failed to unbind UDC: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if content, err := os.ReadFile(udcPath); err == nil {
|
||||
if strings.TrimSpace(string(content)) != "none" {
|
||||
u.log.Warn().Msg("UDC still bound after unbind attempt")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) UpdateGadgetConfig() error {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
@@ -266,13 +322,48 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
|
||||
}
|
||||
|
||||
func (u *UsbGadget) configureUsbGadget(resetUsb bool) error {
|
||||
u.log.Info().
|
||||
Bool("reset_usb", resetUsb).
|
||||
Msg("configuring USB gadget via transaction")
|
||||
|
||||
return u.WithTransaction(func() error {
|
||||
u.log.Info().Msg("Transaction: Mounting configfs")
|
||||
u.tx.MountConfigFS()
|
||||
|
||||
u.log.Info().Msg("Transaction: Creating config path")
|
||||
u.tx.CreateConfigPath()
|
||||
|
||||
u.log.Info().Msg("Transaction: Writing gadget configuration")
|
||||
u.tx.WriteGadgetConfig()
|
||||
|
||||
if resetUsb {
|
||||
u.log.Info().Msg("Transaction: Rebinding USB")
|
||||
u.tx.RebindUsb(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *UsbGadget) VerifyMassStorage() error {
|
||||
if !u.enabledDevices.MassStorage {
|
||||
return nil
|
||||
}
|
||||
|
||||
massStoragePath := path.Join(u.kvmGadgetPath, "functions/mass_storage.usb0")
|
||||
if _, err := os.Stat(massStoragePath); err != nil {
|
||||
return fmt.Errorf("mass_storage function not found: %w", err)
|
||||
}
|
||||
|
||||
lunPath := path.Join(massStoragePath, "lun.0")
|
||||
if _, err := os.Stat(lunPath); err != nil {
|
||||
return fmt.Errorf("mass_storage LUN not found: %w", err)
|
||||
}
|
||||
|
||||
configLink := path.Join(u.configC1Path, "mass_storage.usb0")
|
||||
if _, err := os.Lstat(configLink); err != nil {
|
||||
return fmt.Errorf("mass_storage symlink not found: %w", err)
|
||||
}
|
||||
|
||||
u.log.Info().Msg("mass storage verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
@@ -151,7 +152,10 @@ func (tx *UsbGadgetTransaction) CreateConfigPath() {
|
||||
}
|
||||
|
||||
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
|
||||
tx.log.Info().Msg("=== Building USB gadget configuration ===")
|
||||
|
||||
// create kvm gadget path
|
||||
tx.log.Info().Str("path", tx.kvmGadgetPath).Msg("creating kvm gadget path")
|
||||
tx.mkdirAll(
|
||||
"gadget",
|
||||
tx.kvmGadgetPath,
|
||||
@@ -162,22 +166,43 @@ func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
|
||||
deps := make([]string, 0)
|
||||
deps = append(deps, tx.kvmGadgetPath)
|
||||
|
||||
enabledCount := 0
|
||||
disabledCount := 0
|
||||
for _, val := range tx.orderedConfigItems {
|
||||
key := val.key
|
||||
item := val.item
|
||||
|
||||
// check if the item is enabled in the config
|
||||
if !tx.isGadgetConfigItemEnabled(key) {
|
||||
tx.log.Debug().
|
||||
Str("key", key).
|
||||
Str("device", item.device).
|
||||
Msg("disabling gadget item (not enabled in config)")
|
||||
tx.DisableGadgetItemConfig(item)
|
||||
disabledCount++
|
||||
continue
|
||||
}
|
||||
|
||||
tx.log.Info().
|
||||
Str("key", key).
|
||||
Str("device", item.device).
|
||||
Uint("order", item.order).
|
||||
Msg("configuring gadget item")
|
||||
deps = tx.writeGadgetItemConfig(item, deps)
|
||||
enabledCount++
|
||||
}
|
||||
|
||||
tx.log.Info().
|
||||
Int("enabled_items", enabledCount).
|
||||
Int("disabled_items", disabledCount).
|
||||
Msg("gadget items configuration completed")
|
||||
|
||||
if tx.isGadgetConfigItemEnabled("mtp") {
|
||||
tx.log.Info().Msg("MTP enabled, mounting functionfs and binding UDC")
|
||||
tx.MountFunctionFS()
|
||||
tx.WriteUDC(true)
|
||||
} else {
|
||||
tx.log.Info().Msg("MTP disabled, binding UDC directly")
|
||||
tx.WriteUDC(false)
|
||||
}
|
||||
}
|
||||
@@ -226,6 +251,22 @@ func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, dep
|
||||
beforeChange = append(beforeChange, tx.getDisableKeys()...)
|
||||
}
|
||||
|
||||
// 对于 mass storage LUN 属性,需要确保 UDC 未绑定
|
||||
needsUnbind := strings.Contains(gadgetItemPath, "mass_storage") && strings.Contains(gadgetItemPath, "lun.")
|
||||
if needsUnbind {
|
||||
// 添加一个 unbind UDC 的步骤(如果已绑定)
|
||||
udcPath := path.Join(tx.kvmGadgetPath, "UDC")
|
||||
tx.addFileChange("udc-unbind", RequestedFileChange{
|
||||
Key: "udc-unbind-check",
|
||||
Path: udcPath,
|
||||
ExpectedState: FileStateFile,
|
||||
Description: "check and unbind UDC if needed",
|
||||
DependsOn: files,
|
||||
When: "beforeChange", // 只在需要时执行
|
||||
})
|
||||
beforeChange = append(beforeChange, "udc-unbind-check")
|
||||
}
|
||||
|
||||
if len(item.attrs) > 0 {
|
||||
// write attributes for the item
|
||||
files = append(files, tx.writeGadgetAttrs(
|
||||
@@ -355,9 +396,12 @@ func (tx *UsbGadgetTransaction) WriteUDC(mtpServer bool) {
|
||||
}
|
||||
|
||||
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
|
||||
unbindPath := path.Join(tx.dwc3Path, "unbind")
|
||||
bindPath := path.Join(tx.dwc3Path, "bind")
|
||||
|
||||
// remove the gadget from the UDC
|
||||
tx.addFileChange("udc", RequestedFileChange{
|
||||
Path: path.Join(tx.dwc3Path, "unbind"),
|
||||
Path: unbindPath,
|
||||
ExpectedState: FileStateFileWrite,
|
||||
ExpectedContent: []byte(tx.udc),
|
||||
Description: "unbind UDC",
|
||||
@@ -366,10 +410,10 @@ func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
|
||||
})
|
||||
// bind the gadget to the UDC
|
||||
tx.addFileChange("udc", RequestedFileChange{
|
||||
Path: path.Join(tx.dwc3Path, "bind"),
|
||||
Path: bindPath,
|
||||
ExpectedState: FileStateFileWrite,
|
||||
ExpectedContent: []byte(tx.udc),
|
||||
Description: "bind UDC",
|
||||
DependsOn: []string{path.Join(tx.dwc3Path, "unbind")},
|
||||
DependsOn: []string{unbindPath},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package usbgadget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -118,6 +120,10 @@ func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
|
||||
u.onKeyboardStateChange = &f
|
||||
}
|
||||
|
||||
func (u *UsbGadget) SetOnHidDeviceMissing(f func(device string, err error)) {
|
||||
u.onHidDeviceMissing = &f
|
||||
}
|
||||
|
||||
func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
||||
u.keyboardStateLock.Lock()
|
||||
defer u.keyboardStateLock.Unlock()
|
||||
@@ -177,6 +183,16 @@ func (u *UsbGadget) openKeyboardHidFile() error {
|
||||
var err error
|
||||
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
||||
u.log.Error().
|
||||
Str("device", "hidg0").
|
||||
Str("device_name", "keyboard").
|
||||
Err(err).
|
||||
Msg("HID device file missing, gadget may need reinitialization")
|
||||
if u.onHidDeviceMissing != nil {
|
||||
(*u.onHidDeviceMissing)("keyboard", err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to open hidg0: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package usbgadget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var absoluteMouseConfig = gadgetConfigItem{
|
||||
@@ -69,6 +71,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||
var err error
|
||||
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
||||
u.log.Error().
|
||||
Str("device", "hidg1").
|
||||
Str("device_name", "absolute_mouse").
|
||||
Err(err).
|
||||
Msg("HID device file missing, gadget may need reinitialization")
|
||||
if u.onHidDeviceMissing != nil {
|
||||
(*u.onHidDeviceMissing)("absolute_mouse", err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package usbgadget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var relativeMouseConfig = gadgetConfigItem{
|
||||
@@ -59,7 +61,19 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
||||
var err error
|
||||
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
||||
u.log.Error().
|
||||
Str("device", "hidg2").
|
||||
Str("device_name", "relative_mouse").
|
||||
Err(err).
|
||||
Msg("HID device file missing, gadget may need reinitialization")
|
||||
|
||||
if u.onHidDeviceMissing != nil {
|
||||
(*u.onHidDeviceMissing)("relative_mouse", err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to open hidg2: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ type UsbGadget struct {
|
||||
txLock sync.Mutex
|
||||
|
||||
onKeyboardStateChange *func(state KeyboardState)
|
||||
onHidDeviceMissing *func(device string, err error)
|
||||
|
||||
log *zerolog.Logger
|
||||
|
||||
@@ -140,8 +141,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
|
||||
absMouseAccumulatedWheelY: 0,
|
||||
}
|
||||
if err := g.Init(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to init USB gadget")
|
||||
return nil
|
||||
logger.Error().Err(err).Msg("failed to init USB gadget (will retry later)")
|
||||
}
|
||||
|
||||
return g
|
||||
|
||||
62
jsonrpc.go
62
jsonrpc.go
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
@@ -250,6 +251,51 @@ func rpcSetEDID(edid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcSetForceHpd(forceHpd bool) error {
|
||||
forceHpdValue := 0
|
||||
if forceHpd {
|
||||
forceHpdValue = 1
|
||||
}
|
||||
|
||||
forceHpdPath := "/sys/module/tc35874x/parameters/force_hpd"
|
||||
err := os.WriteFile(forceHpdPath, []byte(fmt.Sprintf("%d\n", forceHpdValue)), 0644)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Bool("force_hpd", forceHpd).Msg("Failed to set force_hpd parameter")
|
||||
return fmt.Errorf("failed to set force_hpd parameter: %w", err)
|
||||
}
|
||||
|
||||
logger.Info().Bool("force_hpd", forceHpd).Msg("Force HPD setting applied")
|
||||
|
||||
config.ForceHpd = forceHpd
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetForceHpd() (bool, error) {
|
||||
forceHpdPath := "/sys/module/tc35874x/parameters/force_hpd"
|
||||
data, err := os.ReadFile(forceHpdPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return config.ForceHpd, nil
|
||||
}
|
||||
logger.Error().Err(err).Msg("Failed to read force_hpd parameter")
|
||||
return config.ForceHpd, fmt.Errorf("failed to read force_hpd parameter: %w", err)
|
||||
}
|
||||
|
||||
forceHpdValue := strings.TrimSpace(string(data))
|
||||
if forceHpdValue == "1" {
|
||||
return true, nil
|
||||
} else if forceHpdValue == "0" {
|
||||
return false, nil
|
||||
} else {
|
||||
logger.Warn().Str("force_hpd_value", forceHpdValue).Msg("Unexpected force_hpd value, using config value")
|
||||
return config.ForceHpd, nil
|
||||
}
|
||||
}
|
||||
|
||||
func rpcGetDevChannelState() (bool, error) {
|
||||
return config.IncludePreRelease, nil
|
||||
}
|
||||
@@ -1050,6 +1096,7 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
|
||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
@@ -1057,6 +1104,8 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
"getVideoState": {Func: rpcGetVideoState},
|
||||
"getUSBState": {Func: rpcGetUSBState},
|
||||
"reinitializeUsbGadget": {Func: rpcReinitializeUsbGadget},
|
||||
"reinitializeUsbGadgetSoft": {Func: rpcReinitializeUsbGadgetSoft},
|
||||
"unmountImage": {Func: rpcUnmountImage},
|
||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||
@@ -1069,6 +1118,8 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||
"getEDID": {Func: rpcGetEDID},
|
||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||
"setForceHpd": {Func: rpcSetForceHpd, Params: []string{"forceHpd"}},
|
||||
"getForceHpd": {Func: rpcGetForceHpd},
|
||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
||||
@@ -1154,5 +1205,16 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"getEasyTierStatus": {Func: rpcGetEasyTierStatus},
|
||||
"getEasyTierConfig": {Func: rpcGetEasyTierConfig},
|
||||
"getEasyTierLog": {Func: rpcGetEasyTierLog},
|
||||
"startVnt": {Func: rpcStartVnt, Params: []string{"config_mode", "token", "device_id", "name", "server_addr", "config_file", "model", "password"}},
|
||||
"stopVnt": {Func: rpcStopVnt},
|
||||
"getVntStatus": {Func: rpcGetVntStatus},
|
||||
"getVntConfig": {Func: rpcGetVntConfig},
|
||||
"getVntConfigFile": {Func: rpcGetVntConfigFile},
|
||||
"getVntLog": {Func: rpcGetVntLog},
|
||||
"getVntInfo": {Func: rpcGetVntInfo},
|
||||
"getEasyTierNodeInfo": {Func: rpcGetEasyTierNodeInfo},
|
||||
"startCloudflared": {Func: rpcStartCloudflared, Params: []string{"token"}},
|
||||
"stopCloudflared": {Func: rpcStopCloudflared},
|
||||
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
||||
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
||||
}
|
||||
|
||||
3
main.go
3
main.go
@@ -31,8 +31,9 @@ func Main() {
|
||||
Interface("app_version", appVersionLocal).
|
||||
Msg("starting KVM")
|
||||
|
||||
go runWatchdog()
|
||||
//go runWatchdog()
|
||||
go confirmCurrentSystem() //A/B system
|
||||
go setForceHpd()
|
||||
|
||||
http.DefaultClient.Timeout = 1 * time.Minute
|
||||
|
||||
|
||||
41
network.go
41
network.go
@@ -56,6 +56,7 @@ func initNetwork() error {
|
||||
},
|
||||
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
||||
config.NetworkConfig = networkConfig
|
||||
config.AppliedNetworkConfig = networkConfig
|
||||
networkStateChanged()
|
||||
},
|
||||
})
|
||||
@@ -73,6 +74,22 @@ func initNetwork() error {
|
||||
|
||||
networkState = state
|
||||
|
||||
if config != nil && config.NetworkConfig != nil {
|
||||
if config.NetworkConfig.PendingReboot.Valid && config.NetworkConfig.PendingReboot.Bool {
|
||||
if config.AppliedNetworkConfig != nil && network.IsSame(config.AppliedNetworkConfig, *config.NetworkConfig) {
|
||||
config.NetworkConfig.PendingReboot = null.BoolFrom(false)
|
||||
_ = SaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config != nil && config.NetworkConfig != nil {
|
||||
if config.NetworkConfig.PendingReboot.Valid && config.NetworkConfig.PendingReboot.Bool {
|
||||
config.NetworkConfig.PendingReboot = null.BoolFrom(false)
|
||||
_ = SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,11 +105,29 @@ func rpcGetNetworkSettings() network.RpcNetworkSettings {
|
||||
}
|
||||
|
||||
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
|
||||
current := networkState.RpcGetNetworkSettings()
|
||||
changedCore := !network.IsSame(current.NetworkConfig, settings.NetworkConfig)
|
||||
if changedCore {
|
||||
settings.NetworkConfig.PendingReboot = null.BoolFrom(true)
|
||||
}
|
||||
|
||||
s := networkState.RpcSetNetworkSettings(settings)
|
||||
if s != nil {
|
||||
return nil, s
|
||||
}
|
||||
|
||||
applied := networkState.RpcGetNetworkSettings()
|
||||
config.NetworkConfig = &applied.NetworkConfig
|
||||
// If we just reverted to the same core config as applied, clear pending_reboot
|
||||
if config.AppliedNetworkConfig != nil {
|
||||
// create copies ignoring PendingReboot
|
||||
a := *config.AppliedNetworkConfig
|
||||
b := applied.NetworkConfig
|
||||
a.PendingReboot = null.Bool{}
|
||||
b.PendingReboot = null.Bool{}
|
||||
if network.IsSame(a, b) {
|
||||
config.NetworkConfig.PendingReboot = null.BoolFrom(false)
|
||||
}
|
||||
}
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,3 +138,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
|
||||
func rpcRenewDHCPLease() error {
|
||||
return networkState.RpcRenewDHCPLease()
|
||||
}
|
||||
|
||||
func rpcRequestDHCPAddress(ip string) error {
|
||||
return networkState.RpcRequestDHCPAddress(ip)
|
||||
}
|
||||
|
||||
2
ota.go
2
ota.go
@@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{
|
||||
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
|
||||
}
|
||||
|
||||
var builtAppVersion = "0.0.3+dev"
|
||||
var builtAppVersion = "0.0.4+dev"
|
||||
|
||||
var updateSource = "github"
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function DashboardNavbar({
|
||||
<div className="inline-block shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://wiki.luckfox.com/Luckfox-Pico/Download"
|
||||
href="https://wiki.luckfox.com/intro/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4"
|
||||
|
||||
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>
|
||||
{$at("If using an adapter, ensure it's compatible and functioning correctly")}
|
||||
</li>
|
||||
<li>
|
||||
{$at("Certain motherboards do not support simultaneous multi-display output")}
|
||||
</li>
|
||||
<li>
|
||||
{$at("Ensure source device is not in sleep mode and outputting a signal")}
|
||||
</li>
|
||||
|
||||
@@ -44,6 +44,30 @@ function KeyboardWrapper() {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// State for locked modifier keys
|
||||
const [lockedModifiers, setLockedModifiers] = useState({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
});
|
||||
|
||||
// Toggle for modifier key behavior: true = lock mode, false = direct trigger mode
|
||||
const [modifierLockMode, setModifierLockMode] = useState(true);
|
||||
|
||||
// Clear locked modifiers when switching to direct mode
|
||||
useEffect(() => {
|
||||
if (!modifierLockMode) {
|
||||
setLockedModifiers({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
});
|
||||
setLayoutName("default");
|
||||
}
|
||||
}, [modifierLockMode]);
|
||||
|
||||
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
|
||||
|
||||
// HID related states
|
||||
@@ -132,11 +156,62 @@ function KeyboardWrapper() {
|
||||
const cleanKey = key.replace(/[()]/g, "");
|
||||
const keyHasShiftModifier = key.includes("(");
|
||||
|
||||
// Check if this is a modifier key press
|
||||
const isModifierKey = key === "ControlLeft" || key === "AltLeft" || key === "MetaLeft" ||
|
||||
key === "AltRight" || key === "MetaRight" || isKeyShift;
|
||||
|
||||
// Handle toggle of layout for shift or caps lock
|
||||
const toggleLayout = () => {
|
||||
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
|
||||
};
|
||||
|
||||
// Handle modifier key press
|
||||
if (key === "ControlLeft") {
|
||||
if (modifierLockMode) {
|
||||
// Lock mode: toggle lock state
|
||||
setLockedModifiers(prev => ({ ...prev, ctrl: !prev.ctrl }));
|
||||
} else {
|
||||
// Direct trigger mode: send key press and release immediately
|
||||
sendKeyboardEvent([], [modifiers["ControlLeft"]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === "AltLeft" || key === "AltRight") {
|
||||
if (modifierLockMode) {
|
||||
setLockedModifiers(prev => ({ ...prev, alt: !prev.alt }));
|
||||
} else {
|
||||
sendKeyboardEvent([], [modifiers[key]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === "MetaLeft" || key === "MetaRight") {
|
||||
if (modifierLockMode) {
|
||||
setLockedModifiers(prev => ({ ...prev, meta: !prev.meta }));
|
||||
} else {
|
||||
sendKeyboardEvent([], [modifiers[key]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isKeyShift) {
|
||||
if (modifierLockMode) {
|
||||
setLockedModifiers(prev => ({ ...prev, shift: !prev.shift }));
|
||||
if (lockedModifiers.shift) {
|
||||
// If unlocking shift, return to default layout
|
||||
setLayoutName("default");
|
||||
} else {
|
||||
// If locking shift, switch to shift layout
|
||||
toggleLayout();
|
||||
}
|
||||
} else {
|
||||
sendKeyboardEvent([], [modifiers["ShiftLeft"]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "CtrlAltDelete") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Delete"]],
|
||||
@@ -166,7 +241,7 @@ function KeyboardWrapper() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyShift || isKeyCaps) {
|
||||
if (isKeyCaps) {
|
||||
toggleLayout();
|
||||
|
||||
if (isCapsLockActive) {
|
||||
@@ -185,25 +260,61 @@ function KeyboardWrapper() {
|
||||
|
||||
// Collect new active keys and modifiers
|
||||
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
|
||||
const newModifiers =
|
||||
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
|
||||
const newModifiers: number[] = [];
|
||||
|
||||
// Add locked modifiers
|
||||
if (lockedModifiers.ctrl) {
|
||||
newModifiers.push(modifiers["ControlLeft"]);
|
||||
}
|
||||
if (lockedModifiers.alt) {
|
||||
newModifiers.push(modifiers["AltLeft"]);
|
||||
}
|
||||
if (lockedModifiers.meta) {
|
||||
newModifiers.push(modifiers["MetaLeft"]);
|
||||
}
|
||||
if (lockedModifiers.shift && !isCapsLockActive) {
|
||||
newModifiers.push(modifiers["ShiftLeft"]);
|
||||
}
|
||||
|
||||
// Add shift modifier for keys with parentheses (if not caps lock and shift not locked)
|
||||
if (keyHasShiftModifier && !isCapsLockActive && !lockedModifiers.shift) {
|
||||
newModifiers.push(modifiers["ShiftLeft"]);
|
||||
}
|
||||
|
||||
// Update current keys and modifiers
|
||||
sendKeyboardEvent(newKeys, newModifiers);
|
||||
|
||||
// If shift was used as a modifier and caps lock is not active, revert to default layout
|
||||
if (keyHasShiftModifier && !isCapsLockActive) {
|
||||
// If shift was used as a modifier and caps lock is not active and shift is not locked, revert to default layout
|
||||
if (keyHasShiftModifier && !isCapsLockActive && !lockedModifiers.shift) {
|
||||
setLayoutName("default");
|
||||
}
|
||||
|
||||
// Auto-unlock modifiers after regular key press (not for combination keys)
|
||||
if (!isModifierKey && newKeys.length > 0) {
|
||||
setLockedModifiers({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
});
|
||||
setLayoutName("default");
|
||||
}
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
},
|
||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
|
||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive, lockedModifiers, modifierLockMode],
|
||||
);
|
||||
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
|
||||
const modifierLockButtons = [
|
||||
lockedModifiers.ctrl ? "ControlLeft" : "",
|
||||
lockedModifiers.alt ? "AltLeft AltRight" : "",
|
||||
lockedModifiers.meta ? "MetaLeft MetaRight" : "",
|
||||
lockedModifiers.shift ? "ShiftLeft ShiftRight" : "",
|
||||
].filter(Boolean).join(" ").trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="transition-all duration-500 ease-in-out"
|
||||
@@ -259,9 +370,11 @@ function KeyboardWrapper() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-y-1">
|
||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||
{$at("Virtual Keyboard")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="absolute right-2">
|
||||
<Button
|
||||
size="XS"
|
||||
@@ -274,21 +387,64 @@ function KeyboardWrapper() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* First row with Lock Mode and combination keys */}
|
||||
<div className="flex items-center bg-blue-50/80 md:flex-row dark:bg-slate-700 gap-x-2 px-2 py-1">
|
||||
{/* Lock Mode toggle - positioned before Ctrl+Alt+Delete */}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-[10px] text-slate-600 dark:text-slate-400 whitespace-nowrap">
|
||||
{$at("Lock Mode")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setModifierLockMode(!modifierLockMode)}
|
||||
className={cx(
|
||||
"relative inline-flex h-4 w-8 items-center rounded-full transition-colors",
|
||||
modifierLockMode ? "bg-blue-500" : "bg-slate-300 dark:bg-slate-600"
|
||||
)}
|
||||
title={modifierLockMode ? $at("Click to switch to direct trigger mode") : $at("Click to switch to lock mode")}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"inline-block h-3 w-3 transform rounded-full bg-white transition-transform",
|
||||
modifierLockMode ? "translate-x-4" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Combination keys */}
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
|
||||
onClick={() => onKeyDown("CtrlAltDelete")}
|
||||
>
|
||||
Ctrl + Alt + Delete
|
||||
</button>
|
||||
<button
|
||||
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
|
||||
onClick={() => onKeyDown("AltMetaEscape")}
|
||||
>
|
||||
Alt + Meta + Escape
|
||||
</button>
|
||||
<button
|
||||
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
|
||||
onClick={() => onKeyDown("CtrlAltBackspace")}
|
||||
>
|
||||
Ctrl + Alt + Backspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-main"
|
||||
layoutName={layoutName}
|
||||
onKeyPress={onKeyDown}
|
||||
buttonTheme={[
|
||||
{
|
||||
class: "combination-key",
|
||||
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
},
|
||||
]}
|
||||
buttonTheme={
|
||||
modifierLockMode && modifierLockButtons
|
||||
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
||||
: []
|
||||
}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||
@@ -297,7 +453,6 @@ function KeyboardWrapper() {
|
||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
shift: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||
|
||||
@@ -10,6 +10,7 @@ import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { cx } from "@/cva.config";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { chars } from "@/keyboardLayouts";
|
||||
import {
|
||||
useHidStore,
|
||||
useMouseStore,
|
||||
@@ -30,15 +31,25 @@ export default function WebRTCVideo() {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
const audioElm = useRef<HTMLAudioElement>(null);
|
||||
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
||||
const [mobileScale, setMobileScale] = useState(1);
|
||||
const [mobileTx, setMobileTx] = useState(0);
|
||||
const [mobileTy, setMobileTy] = useState(0);
|
||||
const activeTouchPointers = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||
const initialPinchDistance = useRef<number | null>(null);
|
||||
const initialPinchScale = useRef<number>(1);
|
||||
const lastPanPoint = useRef<{ x: number; y: number } | null>(null);
|
||||
const lastTapAt = useRef<number>(0);
|
||||
// Store hooks
|
||||
const settings = useSettingsStore();
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
||||
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const {
|
||||
setClientSize: setVideoClientSize,
|
||||
setSize: setVideoSize,
|
||||
@@ -78,6 +89,66 @@ export default function WebRTCVideo() {
|
||||
// Misc states and hooks
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
const safeKeyboardLayout = useMemo(() => {
|
||||
if (keyboardLayout && keyboardLayout.length > 0) return keyboardLayout;
|
||||
return "en_US";
|
||||
}, [keyboardLayout]);
|
||||
|
||||
const sendTextViaHID = useCallback(async (t: string) => {
|
||||
for (const ch of t) {
|
||||
const mapping = chars[safeKeyboardLayout][ch];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
const keyz = [keys[key]];
|
||||
const modz = [(shift ? modifiers["ShiftLeft"] : 0) | (altRight ? modifiers["AltRight"] : 0)];
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(0);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key as keyof typeof keys]);
|
||||
modz.unshift(((accentKey.shift ? modifiers["ShiftLeft"] : 0) | (accentKey.altRight ? modifiers["AltRight"] : 0)));
|
||||
}
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send("keyboardReport", { keys: [kei], modifier: modz[index] }, params => {
|
||||
if ("error" in params) return reject(params.error as unknown as Error);
|
||||
send("keyboardReport", { keys: [], modifier: 0 }, params => {
|
||||
if ("error" in params) return reject(params.error as unknown as Error);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [send, safeKeyboardLayout]);
|
||||
|
||||
const handleGlobalPaste = useCallback(async (e: ClipboardEvent) => {
|
||||
if (!overrideCtrlV) return;
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || "";
|
||||
if (!txt) return;
|
||||
const invalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error
|
||||
[...new Intl.Segmenter().segment(txt)].map(x => x.segment).filter(ch => !chars[safeKeyboardLayout][ch]),
|
||||
),
|
||||
];
|
||||
if (invalid.length > 0) {
|
||||
notifications.error(`Invalid characters: ${invalid.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (isReinitializingGadget) return;
|
||||
try {
|
||||
await sendTextViaHID(txt);
|
||||
notifications.success(`Pasted: "${txt}"`);
|
||||
} catch {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}, [overrideCtrlV, safeKeyboardLayout, isReinitializingGadget, sendTextViaHID]);
|
||||
|
||||
// Video-related
|
||||
useResizeObserver({
|
||||
ref: videoElm as React.RefObject<HTMLElement>,
|
||||
@@ -223,7 +294,7 @@ export default function WebRTCVideo() {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange ", handleFullscreenChange);
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, [releaseKeyboardLock]);
|
||||
|
||||
// Mouse-related
|
||||
@@ -232,16 +303,24 @@ export default function WebRTCVideo() {
|
||||
const sendRelMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
if (settings.mouseMode !== "relative") return;
|
||||
// Don't send mouse events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
// if we ignore the event, double-click will not work
|
||||
// if (x === 0 && y === 0 && buttons === 0) return;
|
||||
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
||||
setMouseMove({ x, y, buttons });
|
||||
},
|
||||
[send, setMouseMove, settings.mouseMode],
|
||||
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const relMouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||
if (pt === "touch") {
|
||||
const touchCount = activeTouchPointers.current.size;
|
||||
if (touchCount >= 2) return;
|
||||
if (mobileScale > 1 && lastPanPoint.current) return;
|
||||
}
|
||||
if (settings.mouseMode !== "relative") return;
|
||||
if (isPointerLockActive === false && isPointerLockPossible) return;
|
||||
|
||||
@@ -255,15 +334,25 @@ export default function WebRTCVideo() {
|
||||
const sendAbsMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
if (settings.mouseMode !== "absolute") return;
|
||||
// Don't send mouse events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
send("absMouseReport", { x, y, buttons });
|
||||
// We set that for the debug info bar
|
||||
setMousePosition(x, y);
|
||||
},
|
||||
[send, setMousePosition, settings.mouseMode],
|
||||
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const absMouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||
if (pt === "touch") {
|
||||
const touchCount = activeTouchPointers.current.size;
|
||||
if (touchCount >= 2) return;
|
||||
if (mobileScale > 1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!videoClientWidth || !videoClientHeight) return;
|
||||
if (settings.mouseMode !== "absolute") return;
|
||||
|
||||
@@ -287,9 +376,13 @@ export default function WebRTCVideo() {
|
||||
offsetY = (videoClientHeight - effectiveHeight) / 2;
|
||||
}
|
||||
|
||||
// Determine input point (reverse transform for touch when zoomed)
|
||||
const inputOffsetX = pt === "touch" ? Math.max(0, Math.min(videoClientWidth, (e.offsetX - mobileTx) / mobileScale)) : e.offsetX;
|
||||
const inputOffsetY = pt === "touch" ? Math.max(0, Math.min(videoClientHeight, (e.offsetY - mobileTy) / mobileScale)) : e.offsetY;
|
||||
|
||||
// Clamp mouse position within the effective video boundaries
|
||||
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
|
||||
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
|
||||
const clampedX = Math.min(Math.max(offsetX, inputOffsetX), offsetX + effectiveWidth);
|
||||
const clampedY = Math.min(Math.max(offsetY, inputOffsetY), offsetY + effectiveHeight);
|
||||
|
||||
// Map clamped mouse position to the video stream's coordinate system
|
||||
const relativeX = (clampedX - offsetX) / effectiveWidth;
|
||||
@@ -303,11 +396,13 @@ export default function WebRTCVideo() {
|
||||
const { buttons } = e;
|
||||
sendAbsMouseMovement(x, y, buttons);
|
||||
},
|
||||
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement],
|
||||
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement, mobileScale, mobileTx, mobileTy],
|
||||
);
|
||||
|
||||
const mouseWheelHandler = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
// Don't send wheel events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
|
||||
if (settings.scrollThrottling && blockWheelEvent) {
|
||||
return;
|
||||
@@ -339,7 +434,7 @@ export default function WebRTCVideo() {
|
||||
setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
|
||||
}
|
||||
},
|
||||
[send, blockWheelEvent, settings],
|
||||
[send, blockWheelEvent, settings, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const resetMousePosition = useCallback(() => {
|
||||
@@ -414,6 +509,16 @@ export default function WebRTCVideo() {
|
||||
|
||||
const keyDownHandler = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
|
||||
console.log("Override Ctrl V");
|
||||
if (isReinitializingGadget) return;
|
||||
if (pasteCaptureRef.current) {
|
||||
pasteCaptureRef.current.value = "";
|
||||
pasteCaptureRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
let code = e.code;
|
||||
@@ -456,10 +561,15 @@ export default function WebRTCVideo() {
|
||||
[
|
||||
handleModifierKeys,
|
||||
sendKeyboardEvent,
|
||||
send,
|
||||
isKeyboardLedManagedByHost,
|
||||
setIsNumLockActive,
|
||||
setIsCapsLockActive,
|
||||
setIsScrollLockActive,
|
||||
overrideCtrlV,
|
||||
pasteCaptureRef,
|
||||
safeKeyboardLayout,
|
||||
isReinitializingGadget,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -568,8 +678,7 @@ export default function WebRTCVideo() {
|
||||
);
|
||||
|
||||
// Setup Keyboard Events
|
||||
useEffect(
|
||||
function setupKeyboardEvents() {
|
||||
useEffect(function setupKeyboardEvents() {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
@@ -582,9 +691,7 @@ export default function WebRTCVideo() {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
},
|
||||
[keyDownHandler, keyUpHandler, resetKeyboardState],
|
||||
);
|
||||
}, [keyDownHandler, keyUpHandler, resetKeyboardState]);
|
||||
|
||||
// Setup Video Event Listeners
|
||||
useEffect(
|
||||
@@ -608,6 +715,14 @@ export default function WebRTCVideo() {
|
||||
[onVideoPlaying, videoKeyUpHandler],
|
||||
);
|
||||
|
||||
// Setup Global Paste Listener (register after handleGlobalPaste is defined)
|
||||
useEffect(function setupPasteListener() {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
document.addEventListener("paste", handleGlobalPaste, { signal });
|
||||
return () => abortController.abort();
|
||||
}, [handleGlobalPaste]);
|
||||
|
||||
// Setup Mouse Events
|
||||
useEffect(
|
||||
function setMouseModeEventListeners() {
|
||||
@@ -652,6 +767,90 @@ export default function WebRTCVideo() {
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const zoomLayerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = zoomLayerRef.current;
|
||||
if (!el) return;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
try { el.setPointerCapture(e.pointerId); } catch {}
|
||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (activeTouchPointers.current.size === 1) {
|
||||
const now = Date.now();
|
||||
if (now - lastTapAt.current < 300) {
|
||||
setMobileScale(1);
|
||||
setMobileTx(0);
|
||||
setMobileTy(0);
|
||||
}
|
||||
lastTapAt.current = now;
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
} else if (activeTouchPointers.current.size === 2) {
|
||||
const pts = Array.from(activeTouchPointers.current.values());
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
initialPinchDistance.current = d;
|
||||
initialPinchScale.current = mobileScale;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
const prev = activeTouchPointers.current.get(e.pointerId);
|
||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
const pts = Array.from(activeTouchPointers.current.values());
|
||||
if (pts.length === 2 && initialPinchDistance.current) {
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
const factor = d / initialPinchDistance.current;
|
||||
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
|
||||
setMobileScale(next);
|
||||
} else if (pts.length === 1 && lastPanPoint.current && prev) {
|
||||
const dx = e.clientX - lastPanPoint.current.x;
|
||||
const dy = e.clientY - lastPanPoint.current.y;
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
setMobileTx(v => v + dx);
|
||||
setMobileTy(v => v + dy);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
activeTouchPointers.current.delete(e.pointerId);
|
||||
if (activeTouchPointers.current.size < 2) {
|
||||
initialPinchDistance.current = null;
|
||||
}
|
||||
if (activeTouchPointers.current.size === 0) {
|
||||
lastPanPoint.current = null;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
el.addEventListener("pointerdown", onPointerDown, { signal });
|
||||
el.addEventListener("pointermove", onPointerMove, { signal });
|
||||
el.addEventListener("pointerup", onPointerUp, { signal });
|
||||
el.addEventListener("pointercancel", onPointerUp, { signal });
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [mobileScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const cw = container.clientWidth;
|
||||
const ch = container.clientHeight;
|
||||
if (!cw || !ch) return;
|
||||
const maxX = (cw * (mobileScale - 1)) / 2;
|
||||
const maxY = (ch * (mobileScale - 1)) / 2;
|
||||
setMobileTx(x => Math.max(-maxX, Math.min(maxX, x)));
|
||||
setMobileTy(y => Math.max(-maxY, Math.min(maxY, y)));
|
||||
}, [mobileScale]);
|
||||
|
||||
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||
if (peerConnection?.connectionState !== "connected") return false;
|
||||
@@ -710,7 +909,15 @@ export default function WebRTCVideo() {
|
||||
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
|
||||
<PointerLockBar show={showPointerLockBar} />
|
||||
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
ref={zoomLayerRef}
|
||||
className="relative flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${mobileTx}px, ${mobileTy}px) scale(${mobileScale})`,
|
||||
transformOrigin: "center center",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoElm}
|
||||
autoPlay={true}
|
||||
@@ -767,6 +974,37 @@ export default function WebRTCVideo() {
|
||||
<div>
|
||||
<InfoBar />
|
||||
</div>
|
||||
<textarea
|
||||
ref={pasteCaptureRef}
|
||||
aria-hidden="true"
|
||||
style={{ position: "fixed", left: -9999, top: -9999, width: 1, height: 1, opacity: 0 }}
|
||||
onPaste={async e => {
|
||||
console.log("Paste event");
|
||||
if (!overrideCtrlV) return;
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || e.currentTarget.value || "";
|
||||
e.currentTarget.blur();
|
||||
if (txt) {
|
||||
const invalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error
|
||||
[...new Intl.Segmenter().segment(txt)].map(x => x.segment).filter(ch => !chars[safeKeyboardLayout][ch]),
|
||||
),
|
||||
];
|
||||
if (invalid.length > 0) {
|
||||
notifications.error(`Invalid characters: ${invalid.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (isReinitializingGadget) return;
|
||||
try {
|
||||
await sendTextViaHID(txt);
|
||||
notifications.success(`Pasted: "${txt}"`);
|
||||
} catch {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 notifications from "@/notifications";
|
||||
import {useReactAt} from 'i18n-auto-extractor/react'
|
||||
import { Checkbox } from "@/components/Checkbox";
|
||||
|
||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||
return { keys, modifier };
|
||||
@@ -28,11 +29,15 @@ export default function PasteModal() {
|
||||
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
|
||||
const [pasteBuffer, setPasteBuffer] = useState<string>("");
|
||||
const close = useClose();
|
||||
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
@@ -59,31 +64,38 @@ export default function PasteModal() {
|
||||
setPasteMode(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
setInvalidChars([]);
|
||||
// keep override state persistent via settings store; do not reset here
|
||||
}, [setDisableVideoFocusTrap, setPasteMode]);
|
||||
|
||||
const onConfirmPaste = useCallback(async () => {
|
||||
setPasteMode(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
||||
// Don't send keyboard events while reinitializing gadget
|
||||
if (isReinitializingGadget) {
|
||||
notifications.error("USB gadget is reinitializing, please wait...");
|
||||
return;
|
||||
}
|
||||
if (!safeKeyboardLayout) return;
|
||||
if (!chars[safeKeyboardLayout]) return;
|
||||
const text = TextAreaRef.current.value;
|
||||
|
||||
const sendText = async (t: string) => {
|
||||
try {
|
||||
for (const char of text) {
|
||||
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
|
||||
if (!key) continue;
|
||||
for (const char of t) {
|
||||
const mapping = chars[safeKeyboardLayout][char];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
|
||||
const keyz = [ keys[key] ];
|
||||
const modz = [ modifierCode(shift, altRight) ];
|
||||
const keyz = [keys[key]];
|
||||
const modz = [modifierCode(shift, altRight)];
|
||||
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key])
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
|
||||
keyz.unshift(keys[accentKey.key]);
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||
}
|
||||
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
@@ -102,11 +114,76 @@ export default function PasteModal() {
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.success(`Pasted: "${t}"`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
||||
};
|
||||
|
||||
await sendText(text);
|
||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout, isReinitializingGadget]);
|
||||
|
||||
const readClipboardToBufferAndSend = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setPasteBuffer(text);
|
||||
const segInvalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(text)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
),
|
||||
];
|
||||
setInvalidChars(segInvalid);
|
||||
if (segInvalid.length === 0) {
|
||||
if (rpcDataChannel?.readyState !== "open" || isReinitializingGadget) return;
|
||||
const sendText = async (t: string) => {
|
||||
try {
|
||||
for (const char of t) {
|
||||
const mapping = chars[safeKeyboardLayout][char];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
|
||||
const keyz = [keys[key]];
|
||||
const modz = [modifierCode(shift, altRight)];
|
||||
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key]);
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||
}
|
||||
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([kei], modz[index]),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.success(`Pasted: "${t}"`);
|
||||
} catch (error) {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
};
|
||||
await sendText(text);
|
||||
} else {
|
||||
notifications.error(`Invalid characters: ${segInvalid.join(", ")}`);
|
||||
}
|
||||
} catch {}
|
||||
}, [safeKeyboardLayout, rpcDataChannel?.readyState, isReinitializingGadget, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (TextAreaRef.current) {
|
||||
@@ -125,6 +202,18 @@ export default function PasteModal() {
|
||||
description={$at("Paste text from your client to the remote host")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-x-2 text-sm">
|
||||
<Checkbox
|
||||
checked={overrideCtrlV}
|
||||
onChange={e => setOverrideCtrlV(e.target.checked)}
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{$at("Use Ctrl+V to paste clipboard to remote")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn opacity-0 space-y-2"
|
||||
style={{
|
||||
@@ -133,7 +222,83 @@ export default function PasteModal() {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||
<div
|
||||
className="w-full"
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
readClipboardToBufferAndSend();
|
||||
}
|
||||
}}
|
||||
onPaste={e => {
|
||||
if (overrideCtrlV) {
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || "";
|
||||
if (txt) {
|
||||
setPasteBuffer(txt);
|
||||
const segInvalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(txt)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
),
|
||||
];
|
||||
setInvalidChars(segInvalid);
|
||||
if (segInvalid.length === 0) {
|
||||
if (rpcDataChannel?.readyState === "open" && !isReinitializingGadget) {
|
||||
const sendText = async (t: string) => {
|
||||
try {
|
||||
for (const char of t) {
|
||||
const mapping = chars[safeKeyboardLayout][char];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
const keyz = [keys[key]];
|
||||
const modz = [modifierCode(shift, altRight)];
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key]);
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||
}
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([kei], modz[index]),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.success(`Pasted: "${t}"`);
|
||||
} catch (error) {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
};
|
||||
sendText(txt);
|
||||
}
|
||||
} else {
|
||||
notifications.error(`Invalid characters: ${segInvalid.join(", ")}`);
|
||||
}
|
||||
} else {
|
||||
readClipboardToBufferAndSend();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!overrideCtrlV && (
|
||||
<>
|
||||
<TextAreaWithLabel
|
||||
ref={TextAreaRef}
|
||||
label={$at("Paste from host")}
|
||||
@@ -172,6 +337,8 @@ export default function PasteModal() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -199,6 +366,7 @@ export default function PasteModal() {
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
{!overrideCtrlV && (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
@@ -206,6 +374,7 @@ export default function PasteModal() {
|
||||
onClick={onConfirmPaste}
|
||||
LeadingIcon={LuCornerDownLeft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
||||
@@ -346,6 +346,9 @@ interface SettingsState {
|
||||
showPressedKeys: boolean;
|
||||
setShowPressedKeys: (show: boolean) => void;
|
||||
|
||||
overrideCtrlV: boolean;
|
||||
setOverrideCtrlV: (enabled: boolean) => void;
|
||||
|
||||
// Video enhancement settings
|
||||
videoSaturation: number;
|
||||
setVideoSaturation: (value: number) => void;
|
||||
@@ -409,6 +412,9 @@ export const useSettingsStore = create(
|
||||
showPressedKeys: true,
|
||||
setShowPressedKeys: show => set({ showPressedKeys: show }),
|
||||
|
||||
overrideCtrlV: false,
|
||||
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||
|
||||
// Video enhancement settings with default values (1.0 = normal)
|
||||
videoSaturation: 1.0,
|
||||
setVideoSaturation: value => set({ videoSaturation: value }),
|
||||
@@ -524,6 +530,9 @@ export interface HidState {
|
||||
|
||||
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed" | "default";
|
||||
setUsbState: (state: HidState["usbState"]) => void;
|
||||
|
||||
isReinitializingGadget: boolean;
|
||||
setIsReinitializingGadget: (reinitializing: boolean) => void;
|
||||
}
|
||||
|
||||
export const useHidStore = create<HidState>((set, get) => ({
|
||||
@@ -571,6 +580,9 @@ export const useHidStore = create<HidState>((set, get) => ({
|
||||
// Add these new properties for USB state
|
||||
usbState: "not attached",
|
||||
setUsbState: state => set({ usbState: state }),
|
||||
|
||||
isReinitializingGadget: false,
|
||||
setIsReinitializingGadget: reinitializing => set({ isReinitializingGadget: reinitializing }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -808,15 +820,25 @@ export type TimeSyncMode =
|
||||
| "custom"
|
||||
| "unknown";
|
||||
|
||||
export interface IPv4StaticConfig {
|
||||
address?: string;
|
||||
netmask?: string;
|
||||
gateway?: string;
|
||||
dns?: string[];
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
hostname: string;
|
||||
domain: string;
|
||||
ipv4_mode: IPv4Mode;
|
||||
ipv4_request_address?: string;
|
||||
ipv4_static?: IPv4StaticConfig;
|
||||
ipv6_mode: IPv6Mode;
|
||||
lldp_mode: LLDPMode;
|
||||
lldp_tx_tlvs: string[];
|
||||
mdns_mode: mDNSMode;
|
||||
time_sync_mode: TimeSyncMode;
|
||||
pending_reboot?: boolean;
|
||||
}
|
||||
|
||||
export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
||||
|
||||
@@ -57,7 +57,13 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||
// The "API" can also "request" data from the client
|
||||
// If the payload has a method, it's a request
|
||||
if ("method" in payload) {
|
||||
if (onRequest) onRequest(payload);
|
||||
if ((payload as JsonRpcRequest).method === "refreshPage") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set("networkChanged", "true");
|
||||
window.location.href = currentUrl.toString();
|
||||
return;
|
||||
}
|
||||
if (onRequest) onRequest(payload as JsonRpcRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
@@ -11,18 +12,31 @@ export default function useKeyboard() {
|
||||
const updateActiveKeysAndModifiers = useHidStore(
|
||||
state => state.updateActiveKeysAndModifiers,
|
||||
);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
|
||||
const sendKeyboardEvent = useCallback(
|
||||
(keys: number[], modifiers: number[]) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
// Don't send keyboard events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
if (usbState !== "configured") return;
|
||||
|
||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
send("keyboardReport", { keys, modifier: accModifier });
|
||||
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
||||
if ("error" in resp) {
|
||||
const msg = (resp.error.data as string) || resp.error.message || "";
|
||||
if (msg.includes("cannot send after transport endpoint shutdown") && usbState === "configured") {
|
||||
notifications.error("Please check if the cable and connection are stable.", { duration: 5000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We do this for the info bar to display the currently pressed keys for the user
|
||||
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
|
||||
},
|
||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
|
||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
||||
);
|
||||
|
||||
const resetKeyboardState = useCallback(() => {
|
||||
|
||||
@@ -315,6 +315,16 @@ video::-webkit-media-controls {
|
||||
@apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-button.modifier-locked {
|
||||
@apply bg-blue-500! text-white! border-blue-600!;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .hg-theme-default .hg-button.modifier-locked {
|
||||
@apply bg-blue-600! text-white! border-blue-700!;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.7) !important;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-row .hg-button-container,
|
||||
.hg-theme-default .hg-row .hg-button:not(:last-child) {
|
||||
@apply mr-[2px]! md:mr-[5px]!;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"1281764038": "Unmount",
|
||||
"2188692750": "Send a Signal to wake up the device connected via USB.",
|
||||
"2191159446": "Save Static IPv4 Settings?",
|
||||
"2316865986": "Name (Optional)",
|
||||
"3899981764": "You can only add a maximum of",
|
||||
"514d8a494f": "Terminal",
|
||||
"ef4f580086": "Paste Text",
|
||||
@@ -77,6 +79,7 @@
|
||||
"a90c4aa86f": "Ensure the HDMI cable securely connected at both ends",
|
||||
"6fd1357371": "Ensure source device is powered on and outputting a signal",
|
||||
"3ea74f7e5e": "If using an adapter, ensure it's compatible and functioning correctly",
|
||||
"db130e0e66": "Certain motherboards do not support simultaneous multi-display output",
|
||||
"54b1e31a57": "Ensure source device is not in sleep mode and outputting a signal",
|
||||
"693a670a35": "Try Wakeup",
|
||||
"d59048f21f": "Learn more",
|
||||
@@ -87,6 +90,9 @@
|
||||
"8fc3022cab": "Click on the video to enable mouse control",
|
||||
"f200763a0d": "Detach",
|
||||
"7193518e6f": "Attach",
|
||||
"1c6a47f19f": "Lock Mode",
|
||||
"b2759484fd": "Click to switch to direct trigger mode",
|
||||
"80461c2be2": "Click to switch to lock mode",
|
||||
"703703879d": "IO Control",
|
||||
"4ae517d1d2": "Configure your io control settings",
|
||||
"258f49887e": "Up",
|
||||
@@ -113,6 +119,7 @@
|
||||
"ae94be3cd5": "Manager",
|
||||
"5fe0273ec6": "Paste text",
|
||||
"2a5a0639a5": "Paste text from your client to the remote host",
|
||||
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
|
||||
"6d161c8084": "Paste from host",
|
||||
"604c45fbf2": "The following characters will not be pasted:",
|
||||
"a7eb9efa0b": "Sending text using keyboard layout:",
|
||||
@@ -217,33 +224,53 @@
|
||||
"3d0de21428": "Update your device access password",
|
||||
"f8508f576c": "Remote",
|
||||
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
||||
"e4abe63a8b": "Connect to TailScale VPN network",
|
||||
"0b86461350": "TailScale use xEdge server",
|
||||
"a7d199ad4f": "Login URL:",
|
||||
"3bef87ee46": "Wait to obtain the Login URL",
|
||||
"a3060e541f": "Quitting...",
|
||||
"0baef6200d": "Network IP",
|
||||
"2faec1f9f8": "Enable",
|
||||
"761c43fec5": "Connect to ZeroTier VPN network",
|
||||
"3cc3bd7438": "Connecting...",
|
||||
"5028274560": "Network ID",
|
||||
"0baef6200d": "Network IP",
|
||||
"a2c4bef9fa": "Connect fail, please retry",
|
||||
"6327b4e59f": "Retry",
|
||||
"37cfbb7f44": "Enter ZeroTier Network ID",
|
||||
"718f8fd90c": "Join in",
|
||||
"18c514b621": "Connect to EasyTier server",
|
||||
"8fc22b3dac": "Network Node",
|
||||
"a3e117fed1": "Network Name",
|
||||
"5f1ffd341a": "Network Secret",
|
||||
"8fc22b3dac": "Network Node",
|
||||
"11a755d598": "Stop",
|
||||
"ce0be71e33": "Log",
|
||||
"3119fca100": "Node Info",
|
||||
"3b92996a28": "Enter EasyTier Network Name",
|
||||
"a4c4c07b3d": "Enter EasyTier Network Secret",
|
||||
"7a1920d611": "Default",
|
||||
"63e0339544": "Enter EasyTier Network Node",
|
||||
"3b92996a28": "Enter EasyTier Network Name",
|
||||
"a4c4c07b3d": "Enter EasyTier Network Secret",
|
||||
"a6122a65ea": "Start",
|
||||
"03eb0dbd4f": "Connect to Frp server",
|
||||
"31a631e8bc": "Config Mode",
|
||||
"c2a012144d": "Config File",
|
||||
"3225a10b07": "Parameters",
|
||||
"fa535ffb25": "Config",
|
||||
"ce545e8797": "Using config file",
|
||||
"459a6f79ad": "Token",
|
||||
"2e8f11ede1": "Device ID",
|
||||
"49ee308734": "Name",
|
||||
"a4d911beb0": "Server Address",
|
||||
"4059b0251f": "Info",
|
||||
"493fbda223": "Edit vnt.ini",
|
||||
"1c1aacac07": "Enter vnt-cli configuration",
|
||||
"788982fb30": "Token (Required)",
|
||||
"7e921a758c": "Enter Vnt Token",
|
||||
"1644a3594c": "Device ID (Optional)",
|
||||
"a6ad3901c6": "Enter Device ID",
|
||||
"2fadcf358b": "Enter Device Name",
|
||||
"0be4dc6ce1": "Server Address (Optional)",
|
||||
"723e86b659": "Enter Server Address",
|
||||
"2a766ad220": "Encryption Algorithm",
|
||||
"b24203b84f": "Password(Optional)",
|
||||
"c412ab8687": "Enter Vnt Password",
|
||||
"af0d799cbf": "Cloudflare Tunnel Token",
|
||||
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
|
||||
"1bbabcdef3": "Edit frpc.toml",
|
||||
"9c088a303a": "Enter frpc configuration",
|
||||
"867cee98fd": "Passwords do not match",
|
||||
@@ -291,9 +318,18 @@
|
||||
"5c43d74dbd": "Control the USB emulation state",
|
||||
"f6c8ddbadf": "Disable USB Emulation",
|
||||
"020b92cfbb": "Enable USB Emulation",
|
||||
"9f55f64b0f": "USB Gadget Reinitialize",
|
||||
"40dc677a89": "Reinitialize USB gadget configuration",
|
||||
"02d2f33ec9": "Reinitialize USB Gadget",
|
||||
"f5ddf02991": "Reboot System",
|
||||
"1dbbf194af": "Restart the device system",
|
||||
"1de72c4fc6": "Reboot",
|
||||
"f43c0398a4": "Reset Configuration",
|
||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||
"0d784092e8": "Reset Config",
|
||||
"a776e925bf": "Reboot System?",
|
||||
"1f070051ff": "Are you sure you want to reboot the system?",
|
||||
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
||||
"a1c58e9422": "Appearance",
|
||||
"d414b664a7": "Customize the look and feel of your KVM interface",
|
||||
"d721757161": "Theme",
|
||||
@@ -391,12 +427,21 @@
|
||||
"d4dccb8ca2": "Save settings",
|
||||
"8750a898cb": "IPv4 Mode",
|
||||
"72c2543791": "Configure IPv4 mode",
|
||||
"168d88811a": "Effective Upon Reboot",
|
||||
"4805e7f806": "Request Address",
|
||||
"536f68587c": "Netmask",
|
||||
"926dec9494": "Gateway",
|
||||
"94c252be0e": "DHCP Information",
|
||||
"902f16cd13": "No DHCP lease information available",
|
||||
"6a802c3684": "IPv6 Mode",
|
||||
"d29b71c737": "Configure the IPv6 mode",
|
||||
"d323009843": "No IPv6 addresses configured",
|
||||
"892eec6b1a": "This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.",
|
||||
"676228894e": "Changes will take effect after a restart.",
|
||||
"70d9be9b13": "Confirm",
|
||||
"50cfb85440": "Save Request Address?",
|
||||
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
|
||||
"cc172c234b": "Change IPv4 Mode?",
|
||||
"a344a29861": "IPv4 mode changes will take effect after a restart.",
|
||||
"697b29c12e": "Back to KVM",
|
||||
"34e2d1989a": "Video",
|
||||
"2c50ab9cb6": "Configure display settings and EDID for optimal compatibility",
|
||||
@@ -411,6 +456,8 @@
|
||||
"c63ecd19a0": "Contrast",
|
||||
"5a31e20e6d": "Contrast level",
|
||||
"4418069f82": "Reset to Default",
|
||||
"839b2e1447": "Force EDID Output",
|
||||
"66e3bd652e": "Force EDID output even when no display is connected",
|
||||
"25793bfcbb": "Adjust the EDID settings for the display",
|
||||
"34d026e0a9": "Custom EDID",
|
||||
"b0258b2bdb": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"1281764038": "卸载",
|
||||
"2188692750": "发送信号以唤醒通过 USB 连接的设备。",
|
||||
"2191159446": "保存静态 IPv4 设置?",
|
||||
"2316865986": "名称(可选)",
|
||||
"3899981764": "你最多只能添加",
|
||||
"514d8a494f": "终端",
|
||||
"ef4f580086": "粘贴文本",
|
||||
@@ -77,6 +79,7 @@
|
||||
"a90c4aa86f": "确保 HDMI 线缆两端连接牢固",
|
||||
"6fd1357371": "确保源设备已开机并输出信号",
|
||||
"3ea74f7e5e": "如果使用适配器,确保其兼容且工作正常",
|
||||
"db130e0e66": "部分主板不支持同时输出多路视频信号",
|
||||
"54b1e31a57": "确保源设备未处于睡眠模式且正在输出信号",
|
||||
"693a670a35": "尝试唤醒",
|
||||
"d59048f21f": "了解更多",
|
||||
@@ -87,6 +90,9 @@
|
||||
"8fc3022cab": "点击视频以启用鼠标控制",
|
||||
"f200763a0d": "分离",
|
||||
"7193518e6f": "固定",
|
||||
"1c6a47f19f": "锁定模式",
|
||||
"b2759484fd": "点击切换到直接触发模式",
|
||||
"80461c2be2": "点击切换到锁定模式",
|
||||
"703703879d": "IO 控制",
|
||||
"4ae517d1d2": "配置您的 IO 输出电平状态",
|
||||
"258f49887e": "高电平",
|
||||
@@ -113,6 +119,7 @@
|
||||
"ae94be3cd5": "管理",
|
||||
"5fe0273ec6": "粘贴文本",
|
||||
"2a5a0639a5": "将文本从客户端粘贴到远程主机",
|
||||
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
|
||||
"6d161c8084": "从主机粘贴",
|
||||
"604c45fbf2": "以下字符将不会被粘贴:",
|
||||
"a7eb9efa0b": "使用键盘布局发送文本:",
|
||||
@@ -217,33 +224,53 @@
|
||||
"3d0de21428": "更新设备访问密码",
|
||||
"f8508f576c": "远程",
|
||||
"a8bb6f5f9f": "管理远程访问设备的方式",
|
||||
"e4abe63a8b": "连接到 TailScale VPN 网络",
|
||||
"0b86461350": "TailScale 使用 xEdge 服务器",
|
||||
"a7d199ad4f": "登录网址:",
|
||||
"3bef87ee46": "等待获取登录网址",
|
||||
"a3060e541f": "退出中...",
|
||||
"0baef6200d": "网络 IP",
|
||||
"2faec1f9f8": "启用",
|
||||
"761c43fec5": "连接到 ZeroTier VPN 网络",
|
||||
"3cc3bd7438": "连接中...",
|
||||
"5028274560": "网络 ID",
|
||||
"0baef6200d": "网络 IP",
|
||||
"a2c4bef9fa": "连接失败,请重试",
|
||||
"6327b4e59f": "重试",
|
||||
"37cfbb7f44": "输入 ZeroTier 网络 ID",
|
||||
"718f8fd90c": "加入",
|
||||
"18c514b621": "连接到 EasyTier 服务器",
|
||||
"8fc22b3dac": "网络节点",
|
||||
"a3e117fed1": "网络名称",
|
||||
"5f1ffd341a": "网络密钥",
|
||||
"8fc22b3dac": "网络节点",
|
||||
"11a755d598": "停止",
|
||||
"ce0be71e33": "日志",
|
||||
"3119fca100": "节点信息",
|
||||
"3b92996a28": "输入 EasyTier 网络名称",
|
||||
"a4c4c07b3d": "输入 EasyTier 网络密钥",
|
||||
"7a1920d611": "默认",
|
||||
"63e0339544": "输入 EasyTier 网络节点",
|
||||
"3b92996a28": "输入 EasyTier 网络名称",
|
||||
"a4c4c07b3d": "输入 EasyTier 网络密钥",
|
||||
"a6122a65ea": "启动",
|
||||
"03eb0dbd4f": "连接到 Frp 服务器",
|
||||
"31a631e8bc": "配置模式",
|
||||
"c2a012144d": "配置文件",
|
||||
"3225a10b07": "参数",
|
||||
"fa535ffb25": "配置",
|
||||
"ce545e8797": "使用配置文件",
|
||||
"459a6f79ad": "令牌",
|
||||
"2e8f11ede1": "设备 ID",
|
||||
"49ee308734": "名称",
|
||||
"a4d911beb0": "服务器地址",
|
||||
"4059b0251f": "信息",
|
||||
"493fbda223": "编辑 vnt.ini",
|
||||
"1c1aacac07": "输入 vnt-cli 配置",
|
||||
"788982fb30": "令牌(必需)",
|
||||
"7e921a758c": "输入 Vnt 令牌",
|
||||
"1644a3594c": "设备 ID(可选)",
|
||||
"a6ad3901c6": "输入设备 ID",
|
||||
"2fadcf358b": "输入设备名称",
|
||||
"0be4dc6ce1": "服务器地址(可选)",
|
||||
"723e86b659": "输入服务器地址",
|
||||
"2a766ad220": "加密算法",
|
||||
"b24203b84f": "密码(可选)",
|
||||
"c412ab8687": "输入 Vnt 密码",
|
||||
"af0d799cbf": "Cloudflare 通道令牌",
|
||||
"c420b0d8f0": "输入 Cloudflare 通道令牌",
|
||||
"1bbabcdef3": "编辑 frpc.toml",
|
||||
"9c088a303a": "输入 frpc 配置",
|
||||
"867cee98fd": "密码不一致",
|
||||
@@ -291,9 +318,18 @@
|
||||
"5c43d74dbd": "控制 USB 复用状态",
|
||||
"f6c8ddbadf": "禁用 USB 复用",
|
||||
"020b92cfbb": "启用 USB 复用",
|
||||
"9f55f64b0f": "USB 设备重新初始化",
|
||||
"40dc677a89": "重新初始化 USB 设备配置",
|
||||
"02d2f33ec9": "重新初始化 USB 设备",
|
||||
"f5ddf02991": "重启系统",
|
||||
"1dbbf194af": "重启设备系统",
|
||||
"1de72c4fc6": "重启",
|
||||
"f43c0398a4": "重置配置",
|
||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||
"0d784092e8": "重置配置",
|
||||
"a776e925bf": "重启系统?",
|
||||
"1f070051ff": "你确定重启系统吗?",
|
||||
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||
"a1c58e9422": "外观",
|
||||
"d414b664a7": "自定义 KVM 界面的外观",
|
||||
"d721757161": "主题",
|
||||
@@ -391,12 +427,21 @@
|
||||
"d4dccb8ca2": "保存设置",
|
||||
"8750a898cb": "IPv4 模式",
|
||||
"72c2543791": "配置 IPv4 模式",
|
||||
"168d88811a": "重启后生效",
|
||||
"4805e7f806": "申请地址",
|
||||
"536f68587c": "网络掩码",
|
||||
"926dec9494": "网关",
|
||||
"94c252be0e": "DHCP 信息",
|
||||
"902f16cd13": "无 DHCP 租约信息",
|
||||
"6a802c3684": "IPv6 模式",
|
||||
"d29b71c737": "配置 IPv6 模式",
|
||||
"d323009843": "未配置 IPv6 地址",
|
||||
"892eec6b1a": "这将请求 DHCP 服务器分配新的 IP 地址。在此过程中,您的设备可能会失去网络连接。",
|
||||
"676228894e": "更改将在重启后生效。",
|
||||
"70d9be9b13": "确认",
|
||||
"50cfb85440": "保存请求的 IPv4 地址?",
|
||||
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
||||
"cc172c234b": "更改 IPv4 模式?",
|
||||
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
||||
"697b29c12e": "返回 KVM",
|
||||
"34e2d1989a": "视频",
|
||||
"2c50ab9cb6": "配置视频显示和 EDID",
|
||||
@@ -411,6 +456,8 @@
|
||||
"c63ecd19a0": "对比度",
|
||||
"5a31e20e6d": "对比度级别",
|
||||
"4418069f82": "恢复默认",
|
||||
"839b2e1447": "强制 EDID 输出",
|
||||
"66e3bd652e": "强制 EDID 输出,即使没有连接显示器",
|
||||
"25793bfcbb": "调整显示器的 EDID 设置",
|
||||
"34d026e0a9": "自定义 EDID",
|
||||
"b0258b2bdb": "EDID 详细信息视频模式兼容性。默认设置在大多数情况下有效,但某些独特的 UEFI/BIOS 可能需要调整。",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {useReactAt} from 'i18n-auto-extractor/react'
|
||||
|
||||
|
||||
import AutoHeight from "@/components/AutoHeight";
|
||||
import { Tabs } from "@/components/Tabs";
|
||||
|
||||
|
||||
export interface TailScaleResponse {
|
||||
@@ -56,6 +57,26 @@ export interface EasyTierResponse {
|
||||
node: string;
|
||||
}
|
||||
|
||||
export interface VntRunningResponse {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface VntResponse {
|
||||
config_mode: string;
|
||||
token: string;
|
||||
device_id: string;
|
||||
name: string;
|
||||
server_addr: string;
|
||||
config_file: string;
|
||||
model?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CloudflaredRunningResponse {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface TLSState {
|
||||
mode: "self-signed" | "custom" | "disabled";
|
||||
@@ -127,6 +148,36 @@ export default function SettingsAccessIndexRoute() {
|
||||
node: "",
|
||||
});
|
||||
|
||||
const [vntConfigMode, setVntConfigMode] = useState("params"); // "params" or "file"
|
||||
const [tempVntToken, setTempVntToken] = useState("");
|
||||
const [tempVntDeviceId, setTempVntDeviceId] = useState("");
|
||||
const [tempVntName, setTempVntName] = useState("");
|
||||
const [tempVntServerAddr, setTempVntServerAddr] = useState("");
|
||||
const [vntConfigFileContent, setVntConfigFileContent] = useState("");
|
||||
const [vntRunningStatus, setVntRunningStatus] = useState<VntRunningResponse>({ running: false });
|
||||
const [showVntLogModal, setShowVntLogModal] = useState(false);
|
||||
const [showVntInfoModal, setShowVntInfoModal] = useState(false);
|
||||
const [vntLog, setVntLog] = useState<string>("");
|
||||
const [vntInfo, setVntInfo] = useState<string>("");
|
||||
const [vntConfig, setVntConfig] = useState<VntResponse>({
|
||||
config_mode: "params",
|
||||
token: "",
|
||||
device_id: "",
|
||||
name: "",
|
||||
server_addr: "",
|
||||
config_file: "",
|
||||
model: "",
|
||||
password: "",
|
||||
});
|
||||
const [tempVntModel, setTempVntModel] = useState("aes_gcm");
|
||||
const [tempVntPassword, setTempVntPassword] = useState("");
|
||||
|
||||
// Cloudflare Tunnel
|
||||
const [cloudflaredRunningStatus, setCloudflaredRunningStatus] = useState<CloudflaredRunningResponse>({ running: false });
|
||||
const [cloudflaredToken, setCloudflaredToken] = useState("");
|
||||
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
|
||||
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
|
||||
|
||||
|
||||
const getTLSState = useCallback(() => {
|
||||
send("getTLSState", {}, resp => {
|
||||
@@ -162,6 +213,59 @@ export default function SettingsAccessIndexRoute() {
|
||||
[send],
|
||||
);
|
||||
|
||||
const getCloudflaredStatus = useCallback(() => {
|
||||
send("getCloudflaredStatus", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get Cloudflare status: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setCloudflaredRunningStatus(resp.result as CloudflaredRunningResponse);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleStartCloudflared = useCallback(() => {
|
||||
if (!cloudflaredToken) {
|
||||
notifications.error("Please enter Cloudflare Tunnel Token");
|
||||
return;
|
||||
}
|
||||
send("startCloudflared", { token: cloudflaredToken }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to start Cloudflare: ${resp.error.data || "Unknown error"}`);
|
||||
setCloudflaredRunningStatus({ running: false });
|
||||
return;
|
||||
}
|
||||
notifications.success("Cloudflare started");
|
||||
setCloudflaredRunningStatus({ running: true });
|
||||
});
|
||||
}, [send, cloudflaredToken]);
|
||||
|
||||
const handleStopCloudflared = useCallback(() => {
|
||||
send("stopCloudflared", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to stop Cloudflare: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
notifications.success("Cloudflare stopped");
|
||||
setCloudflaredRunningStatus({ running: false });
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleGetCloudflaredLog = useCallback(() => {
|
||||
send("getCloudflaredLog", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get Cloudflare log: ${resp.error.data || "Unknown error"}`);
|
||||
setCloudflaredLog("");
|
||||
return;
|
||||
}
|
||||
setCloudflaredLog(resp.result as string);
|
||||
setShowCloudflaredLogModal(true);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
useEffect(() => {
|
||||
getCloudflaredStatus();
|
||||
}, [getCloudflaredStatus]);
|
||||
|
||||
// Handle TLS mode change
|
||||
const handleTlsModeChange = (value: string) => {
|
||||
setTlsMode(value);
|
||||
@@ -484,6 +588,177 @@ export default function SettingsAccessIndexRoute() {
|
||||
}
|
||||
}, [tempEasyTierNetworkNodeMode]);
|
||||
|
||||
const handleStartVnt = useCallback(() => {
|
||||
if (vntConfigMode === "file") {
|
||||
if (!vntConfigFileContent) {
|
||||
notifications.error("Please enter Vnt config file content");
|
||||
return;
|
||||
}
|
||||
setVntConfig({
|
||||
config_mode: "file",
|
||||
token: "",
|
||||
device_id: "",
|
||||
name: "",
|
||||
server_addr: "",
|
||||
config_file: vntConfigFileContent,
|
||||
});
|
||||
send("startVnt", {
|
||||
config_mode: "file",
|
||||
token: "",
|
||||
device_id: "",
|
||||
name: "",
|
||||
server_addr: "",
|
||||
config_file: vntConfigFileContent,
|
||||
model: tempVntModel,
|
||||
password: tempVntPassword,
|
||||
}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to start Vnt: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setVntRunningStatus({ running: false });
|
||||
return;
|
||||
}
|
||||
notifications.success("Vnt started");
|
||||
setVntRunningStatus({ running: true });
|
||||
});
|
||||
} else {
|
||||
if (!tempVntToken) {
|
||||
notifications.error("Please enter Vnt token");
|
||||
return;
|
||||
}
|
||||
setVntConfig({
|
||||
config_mode: "params",
|
||||
token: tempVntToken,
|
||||
device_id: tempVntDeviceId,
|
||||
name: tempVntName,
|
||||
server_addr: tempVntServerAddr,
|
||||
config_file: "",
|
||||
model: tempVntModel,
|
||||
password: tempVntPassword,
|
||||
});
|
||||
send("startVnt", {
|
||||
config_mode: "params",
|
||||
token: tempVntToken,
|
||||
device_id: tempVntDeviceId,
|
||||
name: tempVntName,
|
||||
server_addr: tempVntServerAddr,
|
||||
config_file: "",
|
||||
model: tempVntModel,
|
||||
password: tempVntPassword,
|
||||
}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to start Vnt: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setVntRunningStatus({ running: false });
|
||||
return;
|
||||
}
|
||||
notifications.success("Vnt started");
|
||||
setVntRunningStatus({ running: true });
|
||||
});
|
||||
}
|
||||
}, [send, vntConfigMode, tempVntToken, tempVntDeviceId, tempVntName, tempVntServerAddr, vntConfigFileContent, tempVntModel, tempVntPassword]);
|
||||
|
||||
const handleStopVnt = useCallback(() => {
|
||||
send("stopVnt", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to stop Vnt: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Vnt stopped");
|
||||
setVntRunningStatus({ running: false });
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleGetVntLog = useCallback(() => {
|
||||
send("getVntLog", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get Vnt log: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setVntLog("");
|
||||
return;
|
||||
}
|
||||
setVntLog(resp.result as string);
|
||||
setShowVntLogModal(true);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleGetVntInfo = useCallback(() => {
|
||||
send("getVntInfo", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get Vnt Info: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setVntInfo("");
|
||||
return;
|
||||
}
|
||||
setVntInfo(resp.result as string);
|
||||
setShowVntInfoModal(true);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const getVntConfig = useCallback(() => {
|
||||
send("getVntConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get Vnt config: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = resp.result as VntResponse;
|
||||
setVntConfig({
|
||||
config_mode: result.config_mode || "params",
|
||||
token: result.token,
|
||||
device_id: result.device_id,
|
||||
name: result.name,
|
||||
server_addr: result.server_addr,
|
||||
config_file: result.config_file,
|
||||
model: result.model || "",
|
||||
password: result.password || "",
|
||||
});
|
||||
setVntConfigMode(result.config_mode || "params");
|
||||
if (result.config_file) {
|
||||
setVntConfigFileContent(result.config_file);
|
||||
}
|
||||
if (result.model) setTempVntModel(result.model);
|
||||
if (result.password) setTempVntPassword(result.password);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const getVntConfigFile = useCallback(() => {
|
||||
send("getVntConfigFile", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
return;
|
||||
}
|
||||
const result = resp.result as string;
|
||||
if (result) {
|
||||
setVntConfigFileContent(result);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const getVntStatus = useCallback(() => {
|
||||
send("getVntStatus", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get Vnt status: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setVntRunningStatus(resp.result as VntRunningResponse);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
useEffect(() => {
|
||||
getVntConfig();
|
||||
getVntStatus();
|
||||
getVntConfigFile();
|
||||
}, [getVntStatus, getVntConfig, getVntConfigFile]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -615,25 +890,31 @@ export default function SettingsAccessIndexRoute() {
|
||||
description={$at("Manage the mode of Remote access to the device")}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Add TailScale settings item */}
|
||||
<SettingsItem
|
||||
title="TailScale"
|
||||
badge="Experimental"
|
||||
description={$at("Connect to TailScale VPN network")}
|
||||
>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultTab="tailscale"
|
||||
tabs={[
|
||||
{
|
||||
id: "tailscale",
|
||||
label: "TailScale",
|
||||
content: (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title=""
|
||||
description={$at("TailScale use xEdge server")}
|
||||
>
|
||||
<Checkbox disabled={tailScaleConnectionState !== "disconnected"}
|
||||
{/* Experimental Badge */}
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded border border-red-500 px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400">
|
||||
Experimental
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* TailScale use xEdge server - checkbox on the right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{$at("TailScale use xEdge server")}
|
||||
</span>
|
||||
<Checkbox
|
||||
disabled={tailScaleConnectionState !== "disconnected"}
|
||||
checked={tailScaleXEdge}
|
||||
onChange={e => {
|
||||
if (tailScaleConnectionState !== "disconnected") {
|
||||
@@ -643,7 +924,7 @@ export default function SettingsAccessIndexRoute() {
|
||||
handleTailScaleXEdgeChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
{tailScaleConnectionState === "connecting" && (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
@@ -656,6 +937,7 @@ export default function SettingsAccessIndexRoute() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tailScaleConnectionState === "connected" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
@@ -667,43 +949,40 @@ export default function SettingsAccessIndexRoute() {
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text= { isDisconnecting ? $at("Quitting...") : $at("Quit")}
|
||||
theme="danger"
|
||||
text={isDisconnecting ? $at("Quitting...") : $at("Quit")}
|
||||
onClick={handleTailScaleLogout}
|
||||
disabled={ isDisconnecting === true }
|
||||
disabled={isDisconnecting === true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tailScaleConnectionState === "logined" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Network IP")}
|
||||
{/* IP and Quit button on the same line */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||
IP: {tailScaleIP}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{tailScaleIP}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text= { isDisconnecting ? $at("Quitting...") : $at("Quit")}
|
||||
theme="danger"
|
||||
text={isDisconnecting ? $at("Quitting...") : $at("Quit")}
|
||||
onClick={handleTailScaleLogout}
|
||||
disabled={ isDisconnecting === true }
|
||||
disabled={isDisconnecting === true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tailScaleConnectionState === "closed" && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
<p>Connect fail, please retry</p>
|
||||
</div>
|
||||
)}
|
||||
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
|
||||
|
||||
{((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
@@ -715,26 +994,29 @@ export default function SettingsAccessIndexRoute() {
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Add ZeroTier settings item */}
|
||||
<SettingsItem
|
||||
title="ZeroTier"
|
||||
badge="Experimental"
|
||||
description={$at("Connect to ZeroTier VPN network")}
|
||||
>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "zerotier",
|
||||
label: "ZeroTier",
|
||||
content: (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Experimental Badge */}
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded border border-red-500 px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400">
|
||||
Experimental
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{zeroTierConnectionState === "connecting" && (
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>{$at("Connecting...")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zeroTierConnectionState === "connected" && (
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
@@ -748,13 +1030,14 @@ export default function SettingsAccessIndexRoute() {
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
theme="danger"
|
||||
text={$at("Quit")}
|
||||
onClick={handleZeroTierLogout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zeroTierConnectionState === "logined" && (
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
@@ -776,13 +1059,14 @@ export default function SettingsAccessIndexRoute() {
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
theme="danger"
|
||||
text={$at("Quit")}
|
||||
onClick={handleZeroTierLogout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zeroTierConnectionState === "closed" && (
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
<p>{$at("Connect fail, please retry")}</p>
|
||||
@@ -794,6 +1078,7 @@ export default function SettingsAccessIndexRoute() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(zeroTierConnectionState === "disconnected") && (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
@@ -815,20 +1100,26 @@ export default function SettingsAccessIndexRoute() {
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="EasyTier"
|
||||
description={$at("Connect to EasyTier server")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "easytier",
|
||||
label: "EasyTier",
|
||||
content: (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{ easyTierRunningStatus.running ? (
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Network Node")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{easyTierConfig.node || tempEasyTierNetworkNode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Network Name")}
|
||||
@@ -845,14 +1136,6 @@ export default function SettingsAccessIndexRoute() {
|
||||
{easyTierConfig.secret || tempEasyTierNetworkSecret}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Network Node")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{easyTierConfig.node || tempEasyTierNetworkNode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
@@ -877,6 +1160,33 @@ export default function SettingsAccessIndexRoute() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={$at("Network Node")}
|
||||
description=""
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={tempEasyTierNetworkNodeMode}
|
||||
onChange={e => setTempEasyTierNetworkNodeMode(e.target.value)}
|
||||
options={[
|
||||
{ value: "default", label: $at("Default") },
|
||||
{ value: "custom", label: $at("Custom") },
|
||||
]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
{tempEasyTierNetworkNodeMode === "custom" && (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={$at("Network Node")}
|
||||
value={tempEasyTierNetworkNode}
|
||||
onChange={e => setTempEasyTierNetworkNode(e.target.value)}
|
||||
placeholder={$at("Enter EasyTier Network Node")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
@@ -896,35 +1206,6 @@ export default function SettingsAccessIndexRoute() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={$at("Network Node")}
|
||||
description=""
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={tempEasyTierNetworkNodeMode}
|
||||
onChange={e => setTempEasyTierNetworkNodeMode(e.target.value)}
|
||||
options={[
|
||||
{ value: "default", label: $at("Default") },
|
||||
{ value: "custom", label: $at("Custom") },
|
||||
]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
{tempEasyTierNetworkNodeMode === "custom" && (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={$at("Network Node")}
|
||||
value={tempEasyTierNetworkNode}
|
||||
onChange={e => setTempEasyTierNetworkNode(e.target.value)}
|
||||
placeholder={$at("Enter EasyTier Network Node")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
@@ -936,20 +1217,280 @@ export default function SettingsAccessIndexRoute() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "vnt",
|
||||
label: "Vnt",
|
||||
content: (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{ vntRunningStatus.running ? (
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Config Mode")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{vntConfig.config_mode === "file" ? $at("Config File") : $at("Parameters")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{vntConfig.config_mode === "file" ? (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Config")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{$at("Using config file")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Token")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{vntConfig.token || tempVntToken}
|
||||
</span>
|
||||
</div>
|
||||
{(vntConfig.device_id || tempVntDeviceId) && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Device ID")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{vntConfig.device_id || tempVntDeviceId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(vntConfig.name || tempVntName) && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Name")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{vntConfig.name || tempVntName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(vntConfig.server_addr || tempVntServerAddr) && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{$at("Server Address")}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{vntConfig.server_addr || tempVntServerAddr}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text={$at("Stop")}
|
||||
onClick={handleStopVnt}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={$at("Log")}
|
||||
onClick={handleGetVntLog}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={$at("Info")}
|
||||
onClick={handleGetVntInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Config Mode Selector */}
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Frp"
|
||||
description={$at("Connect to Frp server")}
|
||||
title={$at("Config Mode")}
|
||||
description=""
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={vntConfigMode}
|
||||
onChange={e => setVntConfigMode(e.target.value)}
|
||||
options={[
|
||||
{ value: "params", label: $at("Parameters") },
|
||||
{ value: "file", label: $at("Config File") },
|
||||
]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
{vntConfigMode === "file" ? (
|
||||
// Config File Mode
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={$at("Edit vnt.ini")}
|
||||
placeholder={$at("Enter vnt-cli configuration")}
|
||||
value={vntConfigFileContent || ""}
|
||||
rows={5}
|
||||
onChange={e => setVntConfigFileContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Parameters Mode
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={$at("Token (Required)")}
|
||||
value={tempVntToken}
|
||||
onChange={e => setTempVntToken(e.target.value)}
|
||||
placeholder={$at("Enter Vnt Token")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={$at("Device ID (Optional)")}
|
||||
value={tempVntDeviceId}
|
||||
onChange={e => setTempVntDeviceId(e.target.value)}
|
||||
placeholder={$at("Enter Device ID")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={$at("Name (Optional)")}
|
||||
value={tempVntName}
|
||||
onChange={e => setTempVntName(e.target.value)}
|
||||
placeholder={$at("Enter Device Name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={$at("Server Address (Optional)")}
|
||||
value={tempVntServerAddr}
|
||||
onChange={e => setTempVntServerAddr(e.target.value)}
|
||||
placeholder={$at("Enter Server Address")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={$at("Encryption Algorithm")}
|
||||
description=""
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={tempVntModel}
|
||||
onChange={e => setTempVntModel(e.target.value)}
|
||||
options={[
|
||||
{ value: "aes_gcm", label: "aes_gcm" },
|
||||
{ value: "chacha20_poly1305", label: "chacha20_poly1305" },
|
||||
{ value: "chacha20", label: "chacha20" },
|
||||
{ value: "aes_cbc", label: "aes_cbc" },
|
||||
{ value: "aes_ecb", label: "aes_ecb" },
|
||||
{ value: "sm4_cbc", label: "sm4_cbc" },
|
||||
{ value: "xor", label: "xor" },
|
||||
]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="password"
|
||||
label={$at("Password(Optional)")}
|
||||
value={tempVntPassword}
|
||||
onChange={e => setTempVntPassword(e.target.value)}
|
||||
placeholder={$at("Enter Vnt Password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Start")}
|
||||
onClick={handleStartVnt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cloudflare",
|
||||
label: "Cloudflare",
|
||||
content: (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{cloudflaredRunningStatus.running ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text={$at("Stop")}
|
||||
onClick={handleStopCloudflared}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={$at("Log")}
|
||||
onClick={handleGetCloudflaredLog}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Cloudflare Tunnel Token")}
|
||||
value={cloudflaredToken}
|
||||
onChange={e => setCloudflaredToken(e.target.value)}
|
||||
placeholder={$at("Enter Cloudflare Tunnel Token")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Start")}
|
||||
onClick={handleStartCloudflared}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frp",
|
||||
label: "Frp",
|
||||
content: (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
@@ -963,7 +1504,7 @@ export default function SettingsAccessIndexRoute() {
|
||||
onChange={e => setFrpcToml(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{ frpcRunningStatus.running ? (
|
||||
{frpcRunningStatus.running ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
@@ -991,9 +1532,22 @@ export default function SettingsAccessIndexRoute() {
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<LogDialog
|
||||
open={showCloudflaredLogModal}
|
||||
onClose={() => {
|
||||
setShowCloudflaredLogModal(false);
|
||||
}}
|
||||
title="Cloudflare Log"
|
||||
description={cloudflaredLog}
|
||||
/>
|
||||
|
||||
<LogDialog
|
||||
open={showEasyTierLogModal}
|
||||
onClose={() => {
|
||||
@@ -1021,6 +1575,24 @@ export default function SettingsAccessIndexRoute() {
|
||||
description={frpcLog}
|
||||
/>
|
||||
|
||||
<LogDialog
|
||||
open={showVntLogModal}
|
||||
onClose={() => {
|
||||
setShowVntLogModal(false);
|
||||
}}
|
||||
title="Vnt Log"
|
||||
description={vntLog}
|
||||
/>
|
||||
|
||||
<LogDialog
|
||||
open={showVntInfoModal}
|
||||
onClose={() => {
|
||||
setShowVntInfoModal(false);
|
||||
}}
|
||||
title="Vnt Info"
|
||||
description={vntInfo}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Checkbox from "../components/Checkbox";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
import { TextAreaWithLabel } from "../components/TextArea";
|
||||
import { useSettingsStore } from "../hooks/stores";
|
||||
import { useSettingsStore, useHidStore } from "../hooks/stores";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { isOnDevice } from "../main";
|
||||
import notifications from "../notifications";
|
||||
@@ -24,9 +24,12 @@ export default function SettingsAdvancedRoute() {
|
||||
const [devChannel, setDevChannel] = useState(false);
|
||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
|
||||
|
||||
useEffect(() => {
|
||||
send("getSSHKeyState", {}, resp => {
|
||||
@@ -209,6 +212,47 @@ export default function SettingsAdvancedRoute() {
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("USB Gadget Reinitialize")}
|
||||
description={$at("Reinitialize USB gadget configuration")}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={$at("Reinitialize USB Gadget")}
|
||||
disabled={isReinitializingGadget}
|
||||
loading={isReinitializingGadget}
|
||||
onClick={() => {
|
||||
if (isReinitializingGadget) return;
|
||||
setIsReinitializingGadget(true);
|
||||
send("reinitializeUsbGadget", {}, resp => {
|
||||
setIsReinitializingGadget(false);
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reinitialize USB gadget: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("USB gadget reinitialized successfully");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("Reboot System")}
|
||||
description={$at("Restart the device system")}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text={$at("Reboot")}
|
||||
onClick={() => {
|
||||
setShowRebootConfirm(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("Reset Configuration")}
|
||||
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
||||
@@ -250,6 +294,39 @@ export default function SettingsAdvancedRoute() {
|
||||
confirmText="I Understand, Enable Anyway"
|
||||
onConfirm={confirmLoopbackModeEnable}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRebootConfirm}
|
||||
onClose={() => {
|
||||
setShowRebootConfirm(false);
|
||||
}}
|
||||
title={$at("Reboot System?")}
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
{$at("Are you sure you want to reboot the system?")}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
||||
{$at("The device will restart and you will be disconnected from the web interface.")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
variant="warning"
|
||||
cancelText={$at("Cancel")}
|
||||
confirmText={$at("Reboot")}
|
||||
onConfirm={() => {
|
||||
setShowRebootConfirm(false);
|
||||
send("reboot", { force: false }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reboot: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("System rebooting...");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { LuEthernetPort } from "react-icons/lu";
|
||||
|
||||
import {
|
||||
IPv4Mode,
|
||||
IPv4StaticConfig,
|
||||
IPv6Mode,
|
||||
LLDPMode,
|
||||
mDNSMode,
|
||||
@@ -84,11 +86,25 @@ export default function SettingsNetworkRoute() {
|
||||
|
||||
// We use this to determine whether the settings have changed
|
||||
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||
// We use this to indicate whether saved settings differ from initial (effective) settings
|
||||
const initialNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||
|
||||
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
||||
|
||||
const [customDomain, setCustomDomain] = useState<string>("");
|
||||
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("local");
|
||||
const { id } = useParams();
|
||||
const baselineKey = id ? `network_baseline_${id}` : "network_baseline";
|
||||
const baselineResetKey = id ? `network_baseline_reset_${id}` : "network_baseline_reset";
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get("networkChanged") === "true") {
|
||||
localStorage.setItem(baselineResetKey, "1");
|
||||
url.searchParams.delete("networkChanged");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}, [baselineResetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (networkSettings.domain && networkSettingsLoaded) {
|
||||
@@ -109,10 +125,33 @@ export default function SettingsNetworkRoute() {
|
||||
if ("error" in resp) return;
|
||||
console.log(resp.result);
|
||||
setNetworkSettings(resp.result as NetworkSettings);
|
||||
|
||||
if (!firstNetworkSettings.current) {
|
||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||
}
|
||||
const resetFlag = localStorage.getItem(baselineResetKey);
|
||||
const stored = localStorage.getItem(baselineKey);
|
||||
if (resetFlag) {
|
||||
initialNetworkSettings.current = resp.result as NetworkSettings;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
|
||||
localStorage.removeItem(baselineResetKey);
|
||||
} else if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as NetworkSettings;
|
||||
const server = resp.result as NetworkSettings;
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(server)) {
|
||||
initialNetworkSettings.current = server;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(server));
|
||||
} else {
|
||||
initialNetworkSettings.current = parsed;
|
||||
}
|
||||
} catch {
|
||||
initialNetworkSettings.current = resp.result as NetworkSettings;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
|
||||
}
|
||||
} else {
|
||||
initialNetworkSettings.current = resp.result as NetworkSettings;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
|
||||
}
|
||||
setNetworkSettingsLoaded(true);
|
||||
});
|
||||
}, [send]);
|
||||
@@ -164,7 +203,41 @@ export default function SettingsNetworkRoute() {
|
||||
}, [getNetworkState, getNetworkSettings]);
|
||||
|
||||
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
||||
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode });
|
||||
const newMode = value as IPv4Mode;
|
||||
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
|
||||
|
||||
// Initialize static config if switching to static mode
|
||||
if (newMode === "static" && !updatedSettings.ipv4_static) {
|
||||
updatedSettings.ipv4_static = {
|
||||
address: "",
|
||||
netmask: "",
|
||||
gateway: "",
|
||||
dns: [],
|
||||
};
|
||||
}
|
||||
|
||||
setNetworkSettings(updatedSettings);
|
||||
};
|
||||
|
||||
const handleIpv4RequestAddressChange = (value: string) => {
|
||||
setNetworkSettings({ ...networkSettings, ipv4_request_address: value });
|
||||
};
|
||||
|
||||
const handleIpv4StaticChange = (field: keyof IPv4StaticConfig, value: string | string[]) => {
|
||||
const staticConfig = networkSettings.ipv4_static || {
|
||||
address: "",
|
||||
netmask: "",
|
||||
gateway: "",
|
||||
dns: [],
|
||||
};
|
||||
|
||||
setNetworkSettings({
|
||||
...networkSettings,
|
||||
ipv4_static: {
|
||||
...staticConfig,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
|
||||
@@ -212,6 +285,55 @@ export default function SettingsNetworkRoute() {
|
||||
);
|
||||
|
||||
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
|
||||
const [applyingRequestAddr, setApplyingRequestAddr] = useState(false);
|
||||
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
|
||||
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
|
||||
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
|
||||
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
|
||||
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
|
||||
|
||||
const isIPv4StaticEqual = (a?: IPv4StaticConfig, b?: IPv4StaticConfig) => {
|
||||
const na = a || { address: "", netmask: "", gateway: "", dns: [] };
|
||||
const nb = b || { address: "", netmask: "", gateway: "", dns: [] };
|
||||
const adns = (na.dns || []).map(x => x.trim()).filter(x => x.length > 0).sort();
|
||||
const bdns = (nb.dns || []).map(x => x.trim()).filter(x => x.length > 0).sort();
|
||||
if ((na.address || "").trim() !== (nb.address || "").trim()) return false;
|
||||
if ((na.netmask || "").trim() !== (nb.netmask || "").trim()) return false;
|
||||
if ((na.gateway || "").trim() !== (nb.gateway || "").trim()) return false;
|
||||
if (adns.length !== bdns.length) return false;
|
||||
for (let i = 0; i < adns.length; i++) {
|
||||
if (adns[i] !== bdns[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleApplyRequestAddress = useCallback(() => {
|
||||
const requested = (networkSettings.ipv4_request_address || "").trim();
|
||||
if (!requested) {
|
||||
notifications.error("Please enter a valid Request Address");
|
||||
return;
|
||||
}
|
||||
if (networkSettings.ipv4_mode !== "dhcp") {
|
||||
notifications.error("Request Address is only available in DHCP mode");
|
||||
return;
|
||||
}
|
||||
setApplyingRequestAddr(true);
|
||||
send("setNetworkSettings", { settings: networkSettings }, resp => {
|
||||
if ("error" in resp) {
|
||||
setApplyingRequestAddr(false);
|
||||
return notifications.error(
|
||||
"Failed to save Request Address: " + (resp.error.data ? resp.error.data : resp.error.message),
|
||||
);
|
||||
}
|
||||
setApplyingRequestAddr(false);
|
||||
notifications.success("Request Address saved. Changes will take effect after restart.");
|
||||
});
|
||||
}, [networkSettings, send]);
|
||||
|
||||
useEffect(() => {
|
||||
const dns = (networkSettings.ipv4_static?.dns || []).join(", ");
|
||||
setIpv4StaticDnsText(dns);
|
||||
}, [networkSettings.ipv4_static?.dns]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -337,7 +459,10 @@ export default function SettingsNetworkRoute() {
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
disabled={firstNetworkSettings.current === networkSettings}
|
||||
disabled={
|
||||
firstNetworkSettings.current === networkSettings ||
|
||||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
|
||||
}
|
||||
text={$at("Save settings")}
|
||||
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||
/>
|
||||
@@ -346,17 +471,105 @@ export default function SettingsNetworkRoute() {
|
||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title={$at("IPv4 Mode")} description={$at("Configure IPv4 mode")}>
|
||||
<SettingsItem
|
||||
title={$at("IPv4 Mode")}
|
||||
description={$at("Configure IPv4 mode")}
|
||||
// badge={networkSettings.pending_reboot ? $at("Effective Upon Reboot") : undefined}
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.ipv4_mode}
|
||||
onChange={e => handleIpv4ModeChange(e.target.value)}
|
||||
onChange={e => {
|
||||
const next = e.target.value as IPv4Mode;
|
||||
setPendingIpv4Mode(next);
|
||||
setShowIpv4RestartConfirm(true);
|
||||
}}
|
||||
options={filterUnknown([
|
||||
{ value: "dhcp", label: "DHCP" },
|
||||
// { value: "static", label: "Static" },
|
||||
{ value: "static", label: "Static" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{networkSettings.ipv4_mode === "dhcp" && (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Request Address")}
|
||||
placeholder="192.168.1.100"
|
||||
value={networkSettings.ipv4_request_address || ""}
|
||||
onChange={e => {
|
||||
handleIpv4RequestAddressChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Save")}
|
||||
disabled={applyingRequestAddr || !networkSettings.ipv4_request_address}
|
||||
onClick={() => setShowRequestAddrConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkSettings.ipv4_mode === "static" && (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4 mt-1 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("IP Address")}
|
||||
placeholder="192.168.1.100"
|
||||
value={networkSettings.ipv4_static?.address || ""}
|
||||
onChange={e => {
|
||||
handleIpv4StaticChange("address", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Netmask")}
|
||||
placeholder="255.255.255.0"
|
||||
value={networkSettings.ipv4_static?.netmask || ""}
|
||||
onChange={e => {
|
||||
handleIpv4StaticChange("netmask", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Gateway")}
|
||||
placeholder="192.168.1.1"
|
||||
value={networkSettings.ipv4_static?.gateway || ""}
|
||||
onChange={e => {
|
||||
handleIpv4StaticChange("gateway", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("DNS Servers")}
|
||||
placeholder="8.8.8.8,8.8.4.4"
|
||||
value={ipv4StaticDnsText}
|
||||
onChange={e => setIpv4StaticDnsText(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Save")}
|
||||
onClick={() => setShowApplyStaticConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
)}
|
||||
|
||||
{networkSettings.ipv4_mode === "dhcp" && (
|
||||
<AutoHeight>
|
||||
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
|
||||
<GridCard>
|
||||
@@ -386,6 +599,7 @@ export default function SettingsNetworkRoute() {
|
||||
/>
|
||||
)}
|
||||
</AutoHeight>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title={$at("IPv6 Mode")} description={$at("Configure the IPv6 mode")}>
|
||||
@@ -453,7 +667,7 @@ export default function SettingsNetworkRoute() {
|
||||
open={showRenewLeaseConfirm}
|
||||
onClose={() => setShowRenewLeaseConfirm(false)}
|
||||
title={$at("Renew DHCP Lease")}
|
||||
description={$at("This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.")}
|
||||
description={$at("Changes will take effect after a restart.")}
|
||||
variant="danger"
|
||||
confirmText={$at("Renew DHCP Lease")}
|
||||
cancelText={$at("Cancel")}
|
||||
@@ -462,6 +676,64 @@ export default function SettingsNetworkRoute() {
|
||||
setShowRenewLeaseConfirm(false);
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showApplyStaticConfirm}
|
||||
onClose={() => setShowApplyStaticConfirm(false)}
|
||||
title={$at("Save Static IPv4 Settings?")}
|
||||
description={$at("Changes will take effect after a restart.")}
|
||||
variant="warning"
|
||||
confirmText={$at("Confirm")}
|
||||
cancelText={$at("Cancel")}
|
||||
onConfirm={() => {
|
||||
setShowApplyStaticConfirm(false);
|
||||
const dnsArray = ipv4StaticDnsText
|
||||
.split(",")
|
||||
.map(d => d.trim())
|
||||
.filter(d => d.length > 0);
|
||||
const updatedSettings: NetworkSettings = {
|
||||
...networkSettings,
|
||||
ipv4_static: {
|
||||
...(networkSettings.ipv4_static || { address: "", netmask: "", gateway: "", dns: [] }),
|
||||
dns: dnsArray,
|
||||
},
|
||||
};
|
||||
setNetworkSettingsRemote(updatedSettings);
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showRequestAddrConfirm}
|
||||
onClose={() => setShowRequestAddrConfirm(false)}
|
||||
title={$at("Save Request Address?")}
|
||||
description={$at("This will save the requested IPv4 address. Changes take effect after a restart.")}
|
||||
variant="warning"
|
||||
confirmText={$at("Save")}
|
||||
cancelText={$at("Cancel")}
|
||||
onConfirm={() => {
|
||||
setShowRequestAddrConfirm(false);
|
||||
handleApplyRequestAddress();
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showIpv4RestartConfirm}
|
||||
onClose={() => setShowIpv4RestartConfirm(false)}
|
||||
title={$at("Change IPv4 Mode?")}
|
||||
description={$at("IPv4 mode changes will take effect after a restart.")}
|
||||
variant="warning"
|
||||
confirmText={$at("Confirm")}
|
||||
cancelText={$at("Cancel")}
|
||||
onConfirm={() => {
|
||||
setShowIpv4RestartConfirm(false);
|
||||
if (pendingIpv4Mode) {
|
||||
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: pendingIpv4Mode };
|
||||
if (pendingIpv4Mode === "static" && !updatedSettings.ipv4_static) {
|
||||
updatedSettings.ipv4_static = { address: "", netmask: "", gateway: "", dns: [] };
|
||||
}
|
||||
setNetworkSettings(updatedSettings);
|
||||
setNetworkSettingsRemote(updatedSettings);
|
||||
setPendingIpv4Mode(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from "@/hooks/stores";
|
||||
|
||||
import notifications from "../notifications";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import {useReactAt} from 'i18n-auto-extractor/react'
|
||||
@@ -48,6 +49,7 @@ export default function SettingsVideoRoute() {
|
||||
const [streamQuality, setStreamQuality] = useState("1");
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
const [forceHpd, setForceHpd] = useState(false);
|
||||
|
||||
// Video enhancement settings from store
|
||||
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
||||
@@ -85,8 +87,32 @@ export default function SettingsVideoRoute() {
|
||||
setCustomEdidValue(receivedEdid);
|
||||
}
|
||||
});
|
||||
|
||||
send("getForceHpd", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get force EDID output: ${resp.error.data || "Unknown error"}`);
|
||||
setForceHpd(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setForceHpd(resp.result as boolean);
|
||||
});
|
||||
|
||||
}, [send]);
|
||||
|
||||
const handleForceHpdChange = (checked: boolean) => {
|
||||
send("setForceHpd", { forceHpd: checked }, resp => { // 修复参数名称为forceHpd
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set force EDID output: ${resp.error.data || "Unknown error"}`);
|
||||
setForceHpd(!checked);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(`Force EDID output ${checked ? "enabled" : "disabled"}`);
|
||||
setForceHpd(checked);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStreamQualityChange = (factor: string) => {
|
||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||
if ("error" in resp) {
|
||||
@@ -205,6 +231,17 @@ export default function SettingsVideoRoute() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EDID Force Output Setting */}
|
||||
<SettingsItem
|
||||
title={$at("Force EDID Output")}
|
||||
description={$at("Force EDID output even when no display is connected")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={forceHpd}
|
||||
onChange={e => handleForceHpdChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description={$at("Adjust the EDID settings for the display")}
|
||||
|
||||
@@ -566,12 +566,13 @@ export default function KvmIdRoute() {
|
||||
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
|
||||
|
||||
const setUsbState = useHidStore(state => state.setUsbState);
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
||||
|
||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
|
||||
|
||||
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
|
||||
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
@@ -601,6 +602,42 @@ export default function KvmIdRoute() {
|
||||
setKeyboardLedStateSyncAvailable(true);
|
||||
}
|
||||
|
||||
if (resp.method === "hidDeviceMissing") {
|
||||
const params = resp.params as { device: string; error: string };
|
||||
console.error("HID device missing:", params);
|
||||
|
||||
send("getUsbEmulationState", {}, stateResp => {
|
||||
if ("error" in stateResp) return;
|
||||
const emuEnabled = stateResp.result as boolean;
|
||||
if (!emuEnabled || usbState !== "configured") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReinitializingGadget(true);
|
||||
|
||||
notifications.error(
|
||||
`USB HID device (${params.device}) is missing. Reinitializing USB gadget...`,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
|
||||
send("reinitializeUsbGadgetSoft", {}, (resp) => {
|
||||
setIsReinitializingGadget(false);
|
||||
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reinitialize USB gadget (soft): ${resp.error.message}`,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
} else {
|
||||
notifications.success(
|
||||
"USB gadget soft reinitialized successfully",
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (resp.method === "otaState") {
|
||||
const otaState = resp.params as UpdateState["otaState"];
|
||||
setOtaState(otaState);
|
||||
@@ -624,6 +661,12 @@ export default function KvmIdRoute() {
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.method === "refreshPage") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set("networkChanged", "true");
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
109
usb.go
109
usb.go
@@ -1,6 +1,7 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -43,6 +44,21 @@ func initUsbGadget() {
|
||||
}
|
||||
})
|
||||
|
||||
// Set callback for HID device missing errors
|
||||
gadget.SetOnHidDeviceMissing(func(device string, err error) {
|
||||
usbLogger.Error().
|
||||
Str("device", device).
|
||||
Err(err).
|
||||
Msg("HID device missing, sending notification to client")
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("hidDeviceMissing", map[string]interface{}{
|
||||
"device": device,
|
||||
"error": err.Error(),
|
||||
}, currentSession)
|
||||
}
|
||||
})
|
||||
|
||||
// open the keyboard hid file to listen for keyboard events
|
||||
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
||||
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
|
||||
@@ -136,3 +152,96 @@ func rpcSendUsbWakeupSignal() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rpcReinitializeUsbGadget reinitializes the USB gadget
|
||||
func rpcReinitializeUsbGadget() error {
|
||||
usbLogger.Info().Msg("reinitializing USB gadget (hard)")
|
||||
|
||||
if gadget == nil {
|
||||
return fmt.Errorf("USB gadget not initialized")
|
||||
}
|
||||
|
||||
mediaState, _ := rpcGetVirtualMediaState()
|
||||
if mediaState != nil && (mediaState.Filename != "" || mediaState.URL != "") {
|
||||
usbLogger.Info().Interface("mediaState", mediaState).Msg("virtual media mounted, unmounting before USB reinit")
|
||||
if err := rpcUnmountImage(); err != nil {
|
||||
usbLogger.Warn().Err(err).Msg("failed to unmount virtual media before USB reinit")
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate the gadget instance similar to program restart
|
||||
gadget = usbgadget.NewUsbGadget(
|
||||
"kvm",
|
||||
config.UsbDevices,
|
||||
config.UsbConfig,
|
||||
usbLogger,
|
||||
)
|
||||
|
||||
// Reapply callbacks
|
||||
gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("keyboardLedState", state, currentSession)
|
||||
}
|
||||
})
|
||||
gadget.SetOnHidDeviceMissing(func(device string, err error) {
|
||||
usbLogger.Error().
|
||||
Str("device", device).
|
||||
Err(err).
|
||||
Msg("HID device missing, sending notification to client")
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("hidDeviceMissing", map[string]interface{}{
|
||||
"device": device,
|
||||
"error": err.Error(),
|
||||
}, currentSession)
|
||||
}
|
||||
})
|
||||
|
||||
// Reopen keyboard HID file
|
||||
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
||||
usbLogger.Warn().Err(err).Msg("failed to open keyboard hid file after reinit")
|
||||
}
|
||||
|
||||
// Force a USB state update notification
|
||||
triggerUSBStateUpdate()
|
||||
|
||||
initSystemInfo()
|
||||
|
||||
usbLogger.Info().Msg("USB gadget reinitialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// rpcReinitializeUsbGadgetSoft performs a lightweight refresh:
|
||||
// reapply configuration and rebind without recreating the gadget instance.
|
||||
func rpcReinitializeUsbGadgetSoft() error {
|
||||
usbLogger.Info().Msg("reinitializing USB gadget (soft)")
|
||||
|
||||
if gadget == nil {
|
||||
return fmt.Errorf("USB gadget not initialized")
|
||||
}
|
||||
|
||||
mediaState, _ := rpcGetVirtualMediaState()
|
||||
if mediaState != nil && (mediaState.Filename != "" || mediaState.URL != "") {
|
||||
usbLogger.Info().Interface("mediaState", mediaState).Msg("virtual media mounted, unmounting before USB soft reinit")
|
||||
if err := rpcUnmountImage(); err != nil {
|
||||
usbLogger.Warn().Err(err).Msg("failed to unmount virtual media before USB soft reinit")
|
||||
}
|
||||
}
|
||||
|
||||
// Update gadget configuration (will rebind USB inside)
|
||||
if err := gadget.UpdateGadgetConfig(); err != nil {
|
||||
usbLogger.Error().Err(err).Msg("failed to soft reinitialize USB gadget")
|
||||
return fmt.Errorf("failed to soft reinitialize USB gadget: %w", err)
|
||||
}
|
||||
|
||||
// Reopen keyboard HID file
|
||||
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
||||
usbLogger.Warn().Err(err).Msg("failed to reopen keyboard hid file after soft reinit")
|
||||
}
|
||||
|
||||
// Force a USB state update notification
|
||||
triggerUSBStateUpdate()
|
||||
|
||||
usbLogger.Info().Msg("USB gadget soft reinitialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -997,7 +997,7 @@ func updateMtpWithSDStatus() error {
|
||||
}
|
||||
|
||||
if config.UsbDevices.Mtp {
|
||||
if err := gadget.UnbindUDC(); err != nil {
|
||||
if err := gadget.UnbindUDCToDWC3(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to unbind UDC")
|
||||
}
|
||||
|
||||
|
||||
5
video.go
5
video.go
@@ -49,3 +49,8 @@ func HandleVideoStateMessage(event CtrlResponse) {
|
||||
func rpcGetVideoState() (VideoInputState, error) {
|
||||
return lastVideoState, nil
|
||||
}
|
||||
|
||||
func setForceHpd() error {
|
||||
err := rpcSetForceHpd(config.ForceHpd)
|
||||
return err
|
||||
}
|
||||
|
||||
277
vpn.go
277
vpn.go
@@ -381,6 +381,95 @@ func rpcGetFrpcStatus() (FrpcStatus, error) {
|
||||
return FrpcStatus{Running: frpcRunning()}, nil
|
||||
}
|
||||
|
||||
type CloudflaredStatus struct {
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
func cloudflaredRunning() bool {
|
||||
cmd := exec.Command("pgrep", "-x", "cloudflared")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
var (
|
||||
cloudflaredLogPath = "/tmp/cloudflared.log"
|
||||
)
|
||||
|
||||
func rpcStartCloudflared(token string) error {
|
||||
if cloudflaredRunning() {
|
||||
_ = exec.Command("pkill", "-x", "cloudflared").Run()
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("cloudflared token is empty")
|
||||
}
|
||||
cmd := exec.Command("cloudflared", "tunnel", "run", "--token", token)
|
||||
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logFile.Close()
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start cloudflared failed: %w", err)
|
||||
}
|
||||
config.CloudflaredAutoStart = true
|
||||
config.CloudflaredToken = token
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcStopCloudflared() error {
|
||||
if cloudflaredRunning() {
|
||||
err := exec.Command("pkill", "-x", "cloudflared").Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop cloudflared: %w", err)
|
||||
}
|
||||
}
|
||||
config.CloudflaredAutoStart = false
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetCloudflaredStatus() (CloudflaredStatus, error) {
|
||||
return CloudflaredStatus{Running: cloudflaredRunning()}, nil
|
||||
}
|
||||
|
||||
func rpcGetCloudflaredLog() (string, error) {
|
||||
f, err := os.Open(cloudflaredLogPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("cloudflared log file not exist")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
const want = 30
|
||||
lines := make([]string, 0, want+10)
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
if len(lines) > want {
|
||||
lines = lines[1:]
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
for _, l := range lines {
|
||||
buf = append(buf, l...)
|
||||
buf = append(buf, '\n')
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
type EasytierStatus struct {
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
@@ -504,6 +593,175 @@ func rpcGetEasyTierStatus() (EasytierStatus, error) {
|
||||
return EasytierStatus{Running: easytierRunning()}, nil
|
||||
}
|
||||
|
||||
type VntStatus struct {
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
var (
|
||||
vntLogPath = "/tmp/vnt.log"
|
||||
vntConfigFilePath = "/userdata/vnt/vnt.ini"
|
||||
)
|
||||
|
||||
func vntRunning() bool {
|
||||
cmd := exec.Command("pgrep", "-x", "vnt-cli")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func rpcGetVntLog() (string, error) {
|
||||
f, err := os.Open(vntLogPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("vnt log file not exist")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
const want = 30
|
||||
lines := make([]string, 0, want+10)
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
if len(lines) > want {
|
||||
lines = lines[1:]
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
for _, l := range lines {
|
||||
buf = append(buf, l...)
|
||||
buf = append(buf, '\n')
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func rpcGetVntInfo() (string, error) {
|
||||
cmd := exec.Command("vnt-cli", "--info")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get vnt info: %w", err)
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func rpcGetVntConfig() (VntConfig, error) {
|
||||
return config.VntConfig, nil
|
||||
}
|
||||
|
||||
func rpcGetVntConfigFile() (string, error) {
|
||||
return config.VntConfig.ConfigFile, nil
|
||||
}
|
||||
|
||||
func rpcStartVnt(configMode, token, deviceId, name, serverAddr, configFile string, model string, password string) error {
|
||||
if vntRunning() {
|
||||
_ = exec.Command("pkill", "-x", "vnt-cli").Run()
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
if configMode == "file" {
|
||||
// Use config file mode
|
||||
if configFile == "" {
|
||||
return fmt.Errorf("vnt config file is required in file mode")
|
||||
}
|
||||
|
||||
// Save config file
|
||||
_ = os.MkdirAll(filepath.Dir(vntConfigFilePath), 0700)
|
||||
if err := os.WriteFile(vntConfigFilePath, []byte(configFile), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write vnt config file: %w", err)
|
||||
}
|
||||
|
||||
args = []string{"-f", vntConfigFilePath}
|
||||
} else {
|
||||
// Use params mode (default)
|
||||
if token == "" {
|
||||
return fmt.Errorf("vnt token is required in params mode")
|
||||
}
|
||||
|
||||
args = []string{"-k", token}
|
||||
|
||||
if deviceId != "" {
|
||||
args = append(args, "-d", deviceId)
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
args = append(args, "-n", name)
|
||||
}
|
||||
|
||||
if serverAddr != "" {
|
||||
args = append(args, "-s", serverAddr)
|
||||
}
|
||||
|
||||
// Encryption model and password
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
if password != "" {
|
||||
args = append(args, "-w", password)
|
||||
}
|
||||
|
||||
args = append(args, "--compressor", "lz4")
|
||||
}
|
||||
|
||||
cmd := exec.Command("vnt-cli", args...)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open vnt log file: %w", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start vnt failed: %w", err)
|
||||
} else {
|
||||
config.VntAutoStart = true
|
||||
config.VntConfig = VntConfig{
|
||||
ConfigMode: configMode,
|
||||
Token: token,
|
||||
DeviceId: deviceId,
|
||||
Name: name,
|
||||
ServerAddr: serverAddr,
|
||||
ConfigFile: configFile,
|
||||
Model: model,
|
||||
Password: password,
|
||||
}
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcStopVnt() error {
|
||||
if vntRunning() {
|
||||
err := exec.Command("pkill", "-x", "vnt-cli").Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop vnt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
config.VntAutoStart = false
|
||||
err := SaveConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetVntStatus() (VntStatus, error) {
|
||||
return VntStatus{Running: vntRunning()}, nil
|
||||
}
|
||||
|
||||
func initVPN() {
|
||||
waitVpnCtrlClientConnected()
|
||||
go func() {
|
||||
@@ -540,6 +798,25 @@ func initVPN() {
|
||||
vpnLogger.Error().Err(err).Msg("Failed to auto start easytier")
|
||||
}
|
||||
}
|
||||
|
||||
if config.VntAutoStart {
|
||||
if config.VntConfig.ConfigMode == "file" && config.VntConfig.ConfigFile != "" {
|
||||
if err := rpcStartVnt("file", "", "", "", "", config.VntConfig.ConfigFile, config.VntConfig.Model, config.VntConfig.Password); err != nil {
|
||||
vpnLogger.Error().Err(err).Msg("Failed to auto start vnt (file mode)")
|
||||
}
|
||||
} else if config.VntConfig.Token != "" {
|
||||
if err := rpcStartVnt("params", config.VntConfig.Token, config.VntConfig.DeviceId, config.VntConfig.Name, config.VntConfig.ServerAddr, "", config.VntConfig.Model, config.VntConfig.Password); err != nil {
|
||||
vpnLogger.Error().Err(err).Msg("Failed to auto start vnt (params mode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.CloudflaredAutoStart && config.CloudflaredToken != "" {
|
||||
if err := rpcStartCloudflared(config.CloudflaredToken); err != nil {
|
||||
vpnLogger.Error().Err(err).Msg("Failed to auto start cloudflared")
|
||||
}
|
||||
}
|
||||
|
||||
}()
|
||||
|
||||
go func() {
|
||||
|
||||
Reference in New Issue
Block a user